From 3ada4df78f7ff06e14209746420b10e9fdafb1c5 Mon Sep 17 00:00:00 2001 From: DTTerastar Date: Sun, 5 Apr 2026 16:11:05 -0400 Subject: [PATCH 1/3] Add workouts stats command with per-workout breakdowns Shows computed stats for each workout: bodyweight, duration, sets, reps, volume, and PRs. Each exercise within a workout gets its own stats line with best set and volume. Supports --exercise, --since, and --json flags. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/stats.go | 210 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 cmd/stats.go diff --git a/cmd/stats.go b/cmd/stats.go new file mode 100644 index 0000000..6842641 --- /dev/null +++ b/cmd/stats.go @@ -0,0 +1,210 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/dturner/liftoff-cli/internal/client" + "github.com/spf13/cobra" +) + +var ( + statsSinceFlag string + statsExerciseFlag string + statsJSONFlag bool +) + +// WorkoutStats holds computed stats for a single workout. +type WorkoutStats struct { + Date string `json:"date"` + Bodyweight float64 `json:"bodyweight"` + DurationMin float64 `json:"durationMinutes"` + PRCount int `json:"prCount"` + Exercises []ExerciseStats `json:"exercises"` +} + +// ExerciseStats holds computed stats for a single exercise within a workout. +type ExerciseStats struct { + Name string `json:"name"` + Type string `json:"type"` + Sets int `json:"sets"` + Reps int `json:"reps"` + BestWeight float64 `json:"bestWeight,omitempty"` + BestReps int `json:"bestReps,omitempty"` + Volume float64 `json:"volume,omitempty"` +} + +var statsCmd = &cobra.Command{ + Use: "stats", + Short: "Show per-workout statistics", + RunE: func(cmd *cobra.Command, args []string) error { + c := client.New() + var posts []Post + if err := c.Query("post.getMyPosts", nil, &posts); err != nil { + return err + } + if statsSinceFlag != "" { + since, err := parseSince(statsSinceFlag) + if err != nil { + return err + } + var filtered []Post + for _, p := range posts { + t, err := time.Parse(time.RFC3339Nano, p.StartedAt) + if err != nil || !t.Before(since) { + filtered = append(filtered, p) + } + } + posts = filtered + } + if statsExerciseFlag != "" { + posts = filterExercises(posts, statsExerciseFlag) + } + if len(posts) == 0 { + fmt.Println("No workouts found.") + return nil + } + + stats := computeStats(posts) + if statsJSONFlag { + return printJSON(stats) + } + printStatsText(stats) + return nil + }, +} + +func init() { + workoutsCmd.AddCommand(statsCmd) + statsCmd.Flags().StringVar(&statsSinceFlag, "since", "", "Filter workouts on or after date (e.g. 2025-01-01, 30d, 4w, 6m, 1y)") + statsCmd.Flags().StringVar(&statsExerciseFlag, "exercise", "", "Filter to exercises matching this name (word-prefix match)") + statsCmd.Flags().BoolVar(&statsJSONFlag, "json", false, "Output as JSON") +} + +func computeStats(posts []Post) []WorkoutStats { + stats := make([]WorkoutStats, 0, len(posts)) + for _, p := range posts { + bw, _ := strconv.ParseFloat(strings.TrimSpace(p.Bodyweight), 64) + t, _ := time.Parse(time.RFC3339Nano, p.StartedAt) + + ws := WorkoutStats{ + Date: t.Format("2006-01-02"), + Bodyweight: bw, + DurationMin: parseDurationMin(p.SessionDuration), + PRCount: p.PRCount, + } + + for _, e := range p.ExerciseData { + es := exerciseStats(e, bw) + ws.Exercises = append(ws.Exercises, es) + } + + stats = append(stats, ws) + } + return stats +} + +func exerciseStats(e ExerciseData, bw float64) ExerciseStats { + es := ExerciseStats{ + Name: e.ExerciseName, + Type: e.ExerciseTypes, + } + + for _, s := range e.SetsData { + if s.SetType == "warmup" { + continue + } + es.Sets++ + + switch e.ExerciseTypes { + case "WR": + weight, _ := s.InputOne.Float64() + reps, _ := s.InputTwo.Int64() + es.Reps += int(reps) + es.Volume += weight * float64(reps) + if weight > es.BestWeight || (weight == es.BestWeight && int(reps) > es.BestReps) { + es.BestWeight = weight + es.BestReps = int(reps) + } + case "AB": + assist, _ := s.InputOne.Float64() + reps, _ := s.InputTwo.Int64() + eff := bw - assist + es.Reps += int(reps) + es.Volume += eff * float64(reps) + if eff > es.BestWeight || (eff == es.BestWeight && int(reps) > es.BestReps) { + es.BestWeight = eff + es.BestReps = int(reps) + } + case "BR": + added, _ := s.InputOne.Float64() + reps, _ := s.InputTwo.Int64() + eff := bw + added + es.Reps += int(reps) + es.Volume += eff * float64(reps) + if eff > es.BestWeight || (eff == es.BestWeight && int(reps) > es.BestReps) { + es.BestWeight = eff + es.BestReps = int(reps) + } + case "DD": + // distance/duration — no weight volume + reps, _ := s.InputTwo.Int64() + es.Reps += int(reps) + case "ND": + es.Sets++ // count the set, nothing else to track + } + } + + return es +} + +func parseDurationMin(s string) float64 { + parts := strings.Fields(s) + if len(parts) < 6 { + return 0 + } + h, _ := strconv.ParseFloat(parts[0], 64) + m, _ := strconv.ParseFloat(parts[2], 64) + sec, _ := strconv.ParseFloat(parts[4], 64) + return h*60 + m + sec/60 +} + +func printStatsText(stats []WorkoutStats) { + for i, ws := range stats { + totalSets, totalReps := 0, 0 + totalVolume := 0.0 + for _, e := range ws.Exercises { + totalSets += e.Sets + totalReps += e.Reps + totalVolume += e.Volume + } + + fmt.Printf("%s BW=%.0f %0.fm %d exercises %d sets %d reps", + ws.Date, ws.Bodyweight, ws.DurationMin, + len(ws.Exercises), totalSets, totalReps) + if totalVolume > 0 { + fmt.Printf(" %.0f lb vol", totalVolume) + } + if ws.PRCount > 0 { + fmt.Printf(" %d PRs", ws.PRCount) + } + fmt.Println() + + for _, e := range ws.Exercises { + fmt.Printf(" %-30s %d sets %d reps", e.Name, e.Sets, e.Reps) + if e.BestWeight > 0 { + fmt.Printf(" best=%.0fx%d", e.BestWeight, e.BestReps) + } + if e.Volume > 0 { + fmt.Printf(" vol=%.0f", e.Volume) + } + fmt.Println() + } + + if i < len(stats)-1 { + fmt.Println() + } + } +} From 9619576babac49575d54b02ff4822e25091146da Mon Sep 17 00:00:00 2001 From: DTTerastar Date: Sun, 5 Apr 2026 18:13:42 -0400 Subject: [PATCH 2/3] Add compact exercise stats with monthly graphs Rewrite workouts stats to show per-exercise summaries with PR/recent stats and monthly bar graphs (matching bodyweight stats style). The old per-session view is available via --detail flag. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/stats.go | 313 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 224 insertions(+), 89 deletions(-) diff --git a/cmd/stats.go b/cmd/stats.go index 6842641..2be2e29 100644 --- a/cmd/stats.go +++ b/cmd/stats.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "sort" "strconv" "strings" "time" @@ -14,31 +15,32 @@ var ( statsSinceFlag string statsExerciseFlag string statsJSONFlag bool + statsDetailFlag bool ) -// WorkoutStats holds computed stats for a single workout. -type WorkoutStats struct { - Date string `json:"date"` - Bodyweight float64 `json:"bodyweight"` - DurationMin float64 `json:"durationMinutes"` - PRCount int `json:"prCount"` - Exercises []ExerciseStats `json:"exercises"` -} - -// ExerciseStats holds computed stats for a single exercise within a workout. -type ExerciseStats struct { - Name string `json:"name"` - Type string `json:"type"` +// SessionStats holds stats for one exercise in one workout. +type SessionStats struct { + Date string `json:"date"` + Bodyweight float64 `json:"bodyweight"` Sets int `json:"sets"` Reps int `json:"reps"` BestWeight float64 `json:"bestWeight,omitempty"` BestReps int `json:"bestReps,omitempty"` Volume float64 `json:"volume,omitempty"` + Duration int `json:"duration,omitempty"` + Distance float64 `json:"distance,omitempty"` +} + +// ExerciseSummary holds all sessions for one exercise. +type ExerciseSummary struct { + Name string `json:"name"` + Type string `json:"type"` + Sessions []SessionStats `json:"sessions"` } var statsCmd = &cobra.Command{ Use: "stats", - Short: "Show per-workout statistics", + Short: "Show per-exercise statistics across workouts", RunE: func(cmd *cobra.Command, args []string) error { c := client.New() var posts []Post @@ -67,11 +69,20 @@ var statsCmd = &cobra.Command{ return nil } - stats := computeStats(posts) + // Sort posts oldest-first + sort.Slice(posts, func(i, j int) bool { + return posts[i].StartedAt < posts[j].StartedAt + }) + + summaries := buildSummaries(posts) if statsJSONFlag { - return printJSON(stats) + return printJSON(summaries) + } + if statsDetailFlag { + printSummariesTextDetail(summaries) + } else { + printSummariesText(summaries) } - printStatsText(stats) return nil }, } @@ -81,129 +92,253 @@ func init() { statsCmd.Flags().StringVar(&statsSinceFlag, "since", "", "Filter workouts on or after date (e.g. 2025-01-01, 30d, 4w, 6m, 1y)") statsCmd.Flags().StringVar(&statsExerciseFlag, "exercise", "", "Filter to exercises matching this name (word-prefix match)") statsCmd.Flags().BoolVar(&statsJSONFlag, "json", false, "Output as JSON") + statsCmd.Flags().BoolVar(&statsDetailFlag, "detail", false, "Show per-session breakdown") } -func computeStats(posts []Post) []WorkoutStats { - stats := make([]WorkoutStats, 0, len(posts)) +func buildSummaries(posts []Post) []ExerciseSummary { + // Preserve first-seen order + orderMap := map[string]int{} + dataMap := map[string]*ExerciseSummary{} + for _, p := range posts { bw, _ := strconv.ParseFloat(strings.TrimSpace(p.Bodyweight), 64) t, _ := time.Parse(time.RFC3339Nano, p.StartedAt) - - ws := WorkoutStats{ - Date: t.Format("2006-01-02"), - Bodyweight: bw, - DurationMin: parseDurationMin(p.SessionDuration), - PRCount: p.PRCount, - } + date := t.Format("2006-01-02") for _, e := range p.ExerciseData { - es := exerciseStats(e, bw) - ws.Exercises = append(ws.Exercises, es) + ss := sessionStats(e, bw) + ss.Date = date + ss.Bodyweight = bw + + key := e.ExerciseName + if _, exists := dataMap[key]; !exists { + dataMap[key] = &ExerciseSummary{ + Name: e.ExerciseName, + Type: e.ExerciseTypes, + } + orderMap[key] = len(orderMap) + } + dataMap[key].Sessions = append(dataMap[key].Sessions, ss) } + } - stats = append(stats, ws) + // Sort by first-seen order + result := make([]ExerciseSummary, len(dataMap)) + for key, summary := range dataMap { + result[orderMap[key]] = *summary } - return stats + return result } -func exerciseStats(e ExerciseData, bw float64) ExerciseStats { - es := ExerciseStats{ - Name: e.ExerciseName, - Type: e.ExerciseTypes, - } +func sessionStats(e ExerciseData, bw float64) SessionStats { + ss := SessionStats{} for _, s := range e.SetsData { if s.SetType == "warmup" { continue } - es.Sets++ + ss.Sets++ switch e.ExerciseTypes { case "WR": weight, _ := s.InputOne.Float64() reps, _ := s.InputTwo.Int64() - es.Reps += int(reps) - es.Volume += weight * float64(reps) - if weight > es.BestWeight || (weight == es.BestWeight && int(reps) > es.BestReps) { - es.BestWeight = weight - es.BestReps = int(reps) + ss.Reps += int(reps) + ss.Volume += weight * float64(reps) + if weight > ss.BestWeight || (weight == ss.BestWeight && int(reps) > ss.BestReps) { + ss.BestWeight = weight + ss.BestReps = int(reps) } case "AB": assist, _ := s.InputOne.Float64() reps, _ := s.InputTwo.Int64() eff := bw - assist - es.Reps += int(reps) - es.Volume += eff * float64(reps) - if eff > es.BestWeight || (eff == es.BestWeight && int(reps) > es.BestReps) { - es.BestWeight = eff - es.BestReps = int(reps) + ss.Reps += int(reps) + ss.Volume += eff * float64(reps) + if eff > ss.BestWeight || (eff == ss.BestWeight && int(reps) > ss.BestReps) { + ss.BestWeight = eff + ss.BestReps = int(reps) } case "BR": added, _ := s.InputOne.Float64() reps, _ := s.InputTwo.Int64() eff := bw + added - es.Reps += int(reps) - es.Volume += eff * float64(reps) - if eff > es.BestWeight || (eff == es.BestWeight && int(reps) > es.BestReps) { - es.BestWeight = eff - es.BestReps = int(reps) + ss.Reps += int(reps) + ss.Volume += eff * float64(reps) + if eff > ss.BestWeight || (eff == ss.BestWeight && int(reps) > ss.BestReps) { + ss.BestWeight = eff + ss.BestReps = int(reps) } case "DD": - // distance/duration — no weight volume - reps, _ := s.InputTwo.Int64() - es.Reps += int(reps) - case "ND": - es.Sets++ // count the set, nothing else to track + km, _ := s.InputOne.Float64() + secs, _ := s.InputTwo.Int64() + ss.Reps += int(secs) + ss.Duration += int(secs) + ss.Distance += km + } + } + + return ss +} + +func printSummariesText(summaries []ExerciseSummary) { + for i, ex := range summaries { + fmt.Printf("%s — %d sessions\n", ex.Name, len(ex.Sessions)) + + switch ex.Type { + case "WR", "AB", "BR": + printWeightSummary(ex.Sessions) + case "DD": + printDurationSummary(ex.Sessions) + } + + months := monthlyExerciseStats(ex.Sessions, ex.Type) + if len(months) > 0 && ex.Type != "ND" { + fmt.Println() + printExerciseBarGraph(months, ex.Type) + } + + if i < len(summaries)-1 { + fmt.Println() + } + } +} + +func printWeightSummary(sessions []SessionStats) { + var prIdx int + for i, ss := range sessions { + if ss.BestWeight > sessions[prIdx].BestWeight || + (ss.BestWeight == sessions[prIdx].BestWeight && ss.BestReps > sessions[prIdx].BestReps) { + prIdx = i + } + } + pr := sessions[prIdx] + recent := sessions[len(sessions)-1] + + prDate, _ := time.Parse("2006-01-02", pr.Date) + fmt.Printf(" PR: %.0fx%d (%s)\n", pr.BestWeight, pr.BestReps, prDate.Format("Jan 2006")) + fmt.Printf(" Recent: %.0fx%d\n", recent.BestWeight, recent.BestReps) +} + +func printDurationSummary(sessions []SessionStats) { + var bestIdx int + for i, ss := range sessions { + if ss.Duration > sessions[bestIdx].Duration { + bestIdx = i } } + best := sessions[bestIdx] + recent := sessions[len(sessions)-1] - return es + bestDate, _ := time.Parse("2006-01-02", best.Date) + fmt.Printf(" Best: %s (%s)\n", formatDuration(best.Duration), bestDate.Format("Jan 2006")) + fmt.Printf(" Recent: %s\n", formatDuration(recent.Duration)) } -func parseDurationMin(s string) float64 { - parts := strings.Fields(s) - if len(parts) < 6 { - return 0 +type monthStat struct { + month string + value float64 +} + +func monthlyExerciseStats(sessions []SessionStats, exType string) []monthStat { + monthMap := map[string][]SessionStats{} + for _, ss := range sessions { + key := ss.Date[:7] + monthMap[key] = append(monthMap[key], ss) + } + + var months []monthStat + for key, mSessions := range monthMap { + var val float64 + switch exType { + case "WR", "AB", "BR": + for _, ss := range mSessions { + if ss.BestWeight > val { + val = ss.BestWeight + } + } + case "DD": + for _, ss := range mSessions { + val += float64(ss.Duration) + } + } + months = append(months, monthStat{month: key, value: val}) } - h, _ := strconv.ParseFloat(parts[0], 64) - m, _ := strconv.ParseFloat(parts[2], 64) - sec, _ := strconv.ParseFloat(parts[4], 64) - return h*60 + m + sec/60 + + sort.Slice(months, func(i, j int) bool { + return months[i].month < months[j].month + }) + return months } -func printStatsText(stats []WorkoutStats) { - for i, ws := range stats { - totalSets, totalReps := 0, 0 - totalVolume := 0.0 - for _, e := range ws.Exercises { - totalSets += e.Sets - totalReps += e.Reps - totalVolume += e.Volume +func printExerciseBarGraph(months []monthStat, exType string) { + minVal := months[0].value + maxVal := months[0].value + for _, m := range months { + if m.value < minVal { + minVal = m.value } + if m.value > maxVal { + maxVal = m.value + } + } + chartMin := minVal * 0.9 - fmt.Printf("%s BW=%.0f %0.fm %d exercises %d sets %d reps", - ws.Date, ws.Bodyweight, ws.DurationMin, - len(ws.Exercises), totalSets, totalReps) - if totalVolume > 0 { - fmt.Printf(" %.0f lb vol", totalVolume) + // Determine label width for alignment + maxLabelLen := 0 + labels := make([]string, len(months)) + for i, m := range months { + switch exType { + case "WR", "AB", "BR": + labels[i] = formatWeight(m.value) + case "DD": + labels[i] = formatDuration(int(m.value)) + default: + labels[i] = fmt.Sprintf("%.0f", m.value) } - if ws.PRCount > 0 { - fmt.Printf(" %d PRs", ws.PRCount) + if len(labels[i]) > maxLabelLen { + maxLabelLen = len(labels[i]) } - fmt.Println() + } - for _, e := range ws.Exercises { - fmt.Printf(" %-30s %d sets %d reps", e.Name, e.Sets, e.Reps) - if e.BestWeight > 0 { - fmt.Printf(" best=%.0fx%d", e.BestWeight, e.BestReps) + fmtStr := fmt.Sprintf(" %%s %%%ds %%s\n", maxLabelLen) + for i, m := range months { + barLen := scaledBarLength(m.value, chartMin, maxVal, 40) + fmt.Printf(fmtStr, m.month, labels[i], strings.Repeat("█", barLen)) + } +} + +func formatDuration(totalSecs int) string { + if totalSecs >= 3600 { + h := totalSecs / 3600 + m := (totalSecs % 3600) / 60 + if m == 0 { + return fmt.Sprintf("%dh", h) + } + return fmt.Sprintf("%dh %dm", h, m) + } + m := totalSecs / 60 + s := totalSecs % 60 + if s == 0 { + return fmt.Sprintf("%dm", m) + } + return fmt.Sprintf("%d:%02d", m, s) +} + +func printSummariesTextDetail(summaries []ExerciseSummary) { + for i, ex := range summaries { + fmt.Printf("%s — %d sessions\n", ex.Name, len(ex.Sessions)) + for _, ss := range ex.Sessions { + fmt.Printf(" %s BW=%.0f %d sets %d reps", ss.Date, ss.Bodyweight, ss.Sets, ss.Reps) + if ss.BestWeight > 0 { + fmt.Printf(" %.0fx%d", ss.BestWeight, ss.BestReps) } - if e.Volume > 0 { - fmt.Printf(" vol=%.0f", e.Volume) + if ss.Volume > 0 { + fmt.Printf(" vol=%.0f", ss.Volume) } fmt.Println() } - - if i < len(stats)-1 { + if i < len(summaries)-1 { fmt.Println() } } From 41d7035da8a3a767f57047880914c8de8ce25d4f Mon Sep 17 00:00:00 2001 From: DTTerastar Date: Sun, 5 Apr 2026 18:16:18 -0400 Subject: [PATCH 3/3] Fix release drafter to trigger on push to main, not PRs Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release-drafter.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 6ddc541..036566c 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -1,8 +1,8 @@ name: Release Drafter on: - pull_request: - types: [opened, reopened, synchronize] + push: + branches: [main] permissions: contents: write