diff --git a/cmd/garth/activities.go b/cmd/garth/activities.go index d9dcb96..f6b0610 100644 --- a/cmd/garth/activities.go +++ b/cmd/garth/activities.go @@ -2,6 +2,7 @@ package main import ( "encoding/csv" + "encoding/json" "fmt" "os" "path/filepath" @@ -10,7 +11,9 @@ import ( "sync/atomic" "time" + "github.com/olekukonko/tablewriter" "github.com/spf13/cobra" + "github.com/spf13/viper" "garmin-connect/pkg/garmin" ) @@ -131,11 +134,46 @@ func runListActivities(cmd *cobra.Command, args []string) error { return nil } - fmt.Println("Activities:") - for _, activity := range activities { - fmt.Printf("- ID: %d, Name: %s, Type: %s, Date: %s, Distance: %.2f km, Duration: %.0f s\n", - activity.ActivityID, activity.ActivityName, activity.ActivityType, - activity.Starttime.Format("2006-01-02 15:04:05"), activity.Distance/1000, activity.Duration) + outputFormat := viper.GetString("output") + + switch outputFormat { + case "json": + data, err := json.MarshalIndent(activities, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal activities to JSON: %w", err) + } + fmt.Println(string(data)) + case "csv": + writer := csv.NewWriter(os.Stdout) + defer writer.Flush() + + writer.Write([]string{"ActivityID", "ActivityName", "ActivityType", "StartTime", "Distance(km)", "Duration(s)"}) + for _, activity := range activities { + writer.Write([]string{ + fmt.Sprintf("%d", activity.ActivityID), + activity.ActivityName, + activity.ActivityType, + activity.Starttime.Format("2006-01-02 15:04:05"), + fmt.Sprintf("%.2f", activity.Distance/1000), + fmt.Sprintf("%.0f", activity.Duration), + }) + } + case "table": + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"ID", "Name", "Type", "Date", "Distance (km)", "Duration (s)"}) + for _, activity := range activities { + table.Append([]string{ + fmt.Sprintf("%d", activity.ActivityID), + activity.ActivityName, + activity.ActivityType, + activity.Starttime.Format("2006-01-02 15:04:05"), + fmt.Sprintf("%.2f", activity.Distance/1000), + fmt.Sprintf("%.0f", activity.Duration), + }) + } + table.Render() + default: + return fmt.Errorf("unsupported output format: %s", outputFormat) } return nil diff --git a/cmd/garth/health.go b/cmd/garth/health.go index a7ff2e8..079c8d1 100644 --- a/cmd/garth/health.go +++ b/cmd/garth/health.go @@ -1,10 +1,15 @@ package main import ( + "encoding/csv" + "encoding/json" "fmt" + "os" "time" + "github.com/olekukonko/tablewriter" "github.com/spf13/cobra" + "github.com/spf13/viper" "garmin-connect/pkg/garmin" ) @@ -50,6 +55,7 @@ var ( healthDays int healthWeek bool healthYesterday bool + healthAggregate string ) func init() { @@ -58,15 +64,19 @@ func init() { healthCmd.AddCommand(sleepCmd) sleepCmd.Flags().StringVar(&healthDateFrom, "from", "", "Start date for data fetching (YYYY-MM-DD)") sleepCmd.Flags().StringVar(&healthDateTo, "to", "", "End date for data fetching (YYYY-MM-DD)") + sleepCmd.Flags().StringVar(&healthAggregate, "aggregate", "", "Aggregate data by (day, week, month, year)") healthCmd.AddCommand(hrvCmd) hrvCmd.Flags().IntVar(&healthDays, "days", 0, "Number of past days to fetch data for") + hrvCmd.Flags().StringVar(&healthAggregate, "aggregate", "", "Aggregate data by (day, week, month, year)") healthCmd.AddCommand(stressCmd) stressCmd.Flags().BoolVar(&healthWeek, "week", false, "Fetch data for the current week") + stressCmd.Flags().StringVar(&healthAggregate, "aggregate", "", "Aggregate data by (day, week, month, year)") healthCmd.AddCommand(bodyBatteryCmd) bodyBatteryCmd.Flags().BoolVar(&healthYesterday, "yesterday", false, "Fetch data for yesterday") + bodyBatteryCmd.Flags().StringVar(&healthAggregate, "aggregate", "", "Aggregate data by (day, week, month, year)") } func runSleep(cmd *cobra.Command, args []string) error { @@ -110,15 +120,121 @@ func runSleep(cmd *cobra.Command, args []string) error { return nil } - fmt.Println("Sleep Data:") - for _, data := range sleepData { - fmt.Printf("- Date: %s, Score: %d, Total Sleep: %s\n", - data.Date.Format("2006-01-02"), data.SleepScore, (time.Duration(data.TotalSleepSeconds) * time.Second).String()) + // Apply aggregation if requested + if healthAggregate != "" { + aggregatedSleep := make(map[string]struct { + TotalSleepSeconds int + SleepScore int + Count int + }) + + for _, data := range sleepData { + key := "" + switch healthAggregate { + case "day": + key = data.Date.Format("2006-01-02") + case "week": + year, week := data.Date.ISOWeek() + key = fmt.Sprintf("%d-W%02d", year, week) + case "month": + key = data.Date.Format("2006-01") + case "year": + key = data.Date.Format("2006") + default: + return fmt.Errorf("unsupported aggregation period: %s", healthAggregate) + } + + entry := aggregatedSleep[key] + entry.TotalSleepSeconds += data.TotalSleepSeconds + entry.SleepScore += data.SleepScore + entry.Count++ + aggregatedSleep[key] = entry + } + + // Convert aggregated data back to a slice for output + sleepData = []garmin.SleepData{} + for key, entry := range aggregatedSleep { + sleepData = append(sleepData, garmin.SleepData{ + Date: parseAggregationKey(key, healthAggregate), // Helper to parse key back to date + TotalSleepSeconds: entry.TotalSleepSeconds / entry.Count, + SleepScore: entry.SleepScore / entry.Count, + }) + } + } + + outputFormat := viper.GetString("output") + + switch outputFormat { + case "json": + data, err := json.MarshalIndent(sleepData, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal sleep data to JSON: %w", err) + } + fmt.Println(string(data)) + case "csv": + writer := csv.NewWriter(os.Stdout) + defer writer.Flush() + + writer.Write([]string{"Date", "SleepScore", "TotalSleepSeconds", "DeepSleepSeconds", "LightSleepSeconds", "RemSleepSeconds", "AwakeSleepSeconds"}) + for _, data := range sleepData { + writer.Write([]string{ + data.Date.Format("2006-01-02"), + fmt.Sprintf("%d", data.SleepScore), + fmt.Sprintf("%d", data.TotalSleepSeconds), + fmt.Sprintf("%d", data.DeepSleepSeconds), + fmt.Sprintf("%d", data.LightSleepSeconds), + fmt.Sprintf("%d", data.RemSleepSeconds), + fmt.Sprintf("%d", data.AwakeSleepSeconds), + }) + } + case "table": + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Date", "Score", "Total Sleep", "Deep", "Light", "REM", "Awake"}) + for _, data := range sleepData { + table.Append([]string{ + data.Date.Format("2006-01-02"), + fmt.Sprintf("%d", data.SleepScore), + (time.Duration(data.TotalSleepSeconds) * time.Second).String(), + (time.Duration(data.DeepSleepSeconds) * time.Second).String(), + (time.Duration(data.LightSleepSeconds) * time.Second).String(), + (time.Duration(data.RemSleepSeconds) * time.Second).String(), + (time.Duration(data.AwakeSleepSeconds) * time.Second).String(), + }) + } + table.Render() + default: + return fmt.Errorf("unsupported output format: %s", outputFormat) } return nil } +// Helper function to parse aggregation key back to a time.Time object +func parseAggregationKey(key, aggregate string) time.Time { + switch aggregate { + case "day": + t, _ := time.Parse("2006-01-02", key) + return t + case "week": + year, _ := strconv.Atoi(key[:4]) + week, _ := strconv.Atoi(key[6:]) + t := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC) + // Find the first Monday of the year + for t.Weekday() != time.Monday { + t = t.AddDate(0, 0, 1) + } + // Add weeks + return t.AddDate(0, 0, (week-1)*7) + case "month": + t, _ := time.Parse("2006-01", key) + return t + case "year": + t, _ := time.Parse("2006", key) + return t + } + return time.Time{} +} + func runHrv(cmd *cobra.Command, args []string) error { garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable if err != nil { @@ -145,9 +261,77 @@ func runHrv(cmd *cobra.Command, args []string) error { return nil } - fmt.Println("HRV Data:") - for _, data := range hrvData { - fmt.Printf("- Date: %s, HRV: %.2f\n", data.Date.Format("2006-01-02"), data.HrvValue) + // Apply aggregation if requested + if healthAggregate != "" { + aggregatedHrv := make(map[string]struct { + HrvValue float64 + Count int + }) + + for _, data := range hrvData { + key := "" + switch healthAggregate { + case "day": + key = data.Date.Format("2006-01-02") + case "week": + year, week := data.Date.ISOWeek() + key = fmt.Sprintf("%d-W%02d", year, week) + case "month": + key = data.Date.Format("2006-01") + case "year": + key = data.Date.Format("2006") + default: + return fmt.Errorf("unsupported aggregation period: %s", healthAggregate) + } + + entry := aggregatedHrv[key] + entry.HrvValue += data.HrvValue + entry.Count++ + aggregatedHrv[key] = entry + } + + // Convert aggregated data back to a slice for output + hrvData = []garmin.HrvData{} + for key, entry := range aggregatedHrv { + hrvData = append(hrvData, garmin.HrvData{ + Date: parseAggregationKey(key, healthAggregate), // Helper to parse key back to date + HrvValue: entry.HrvValue / float64(entry.Count), + }) + } + } + + outputFormat := viper.GetString("output") + + switch outputFormat { + case "json": + data, err := json.MarshalIndent(hrvData, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal HRV data to JSON: %w", err) + } + fmt.Println(string(data)) + case "csv": + writer := csv.NewWriter(os.Stdout) + defer writer.Flush() + + writer.Write([]string{"Date", "HRV Value"}) + for _, data := range hrvData { + writer.Write([]string{ + data.Date.Format("2006-01-02"), + fmt.Sprintf("%.2f", data.HrvValue), + }) + } + case "table": + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Date", "HRV Value"}) + for _, data := range hrvData { + table.Append([]string{ + data.Date.Format("2006-01-02"), + fmt.Sprintf("%.2f", data.HrvValue), + }) + } + table.Render() + default: + return fmt.Errorf("unsupported output format: %s", outputFormat) } return nil @@ -187,10 +371,82 @@ func runStress(cmd *cobra.Command, args []string) error { return nil } - fmt.Println("Stress Data:") - for _, data := range stressData { - fmt.Printf("- Date: %s, Stress Level: %d, Rest Stress Level: %d\n", - data.Date.Format("2006-01-02"), data.StressLevel, data.RestStressLevel) + // Apply aggregation if requested + if healthAggregate != "" { + aggregatedStress := make(map[string]struct { + StressLevel int + RestStressLevel int + Count int + }) + + for _, data := range stressData { + key := "" + switch healthAggregate { + case "day": + key = data.Date.Format("2006-01-02") + case "week": + year, week := data.Date.ISOWeek() + key = fmt.Sprintf("%d-W%02d", year, week) + case "month": + key = data.Date.Format("2006-01") + case "year": + key = data.Date.Format("2006") + default: + return fmt.Errorf("unsupported aggregation period: %s", healthAggregate) + } + + entry := aggregatedStress[key] + entry.StressLevel += data.StressLevel + entry.RestStressLevel += data.RestStressLevel + entry.Count++ + aggregatedStress[key] = entry + } + + // Convert aggregated data back to a slice for output + stressData = []garmin.StressData{} + for key, entry := range aggregatedStress { + stressData = append(stressData, garmin.StressData{ + Date: parseAggregationKey(key, healthAggregate), // Helper to parse key back to date + StressLevel: entry.StressLevel / entry.Count, + RestStressLevel: entry.RestStressLevel / entry.Count, + }) + } + } + + outputFormat := viper.GetString("output") + + switch outputFormat { + case "json": + data, err := json.MarshalIndent(stressData, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal stress data to JSON: %w", err) + } + fmt.Println(string(data)) + case "csv": + writer := csv.NewWriter(os.Stdout) + defer writer.Flush() + + writer.Write([]string{"Date", "StressLevel", "RestStressLevel"}) + for _, data := range stressData { + writer.Write([]string{ + data.Date.Format("2006-01-02"), + fmt.Sprintf("%d", data.StressLevel), + fmt.Sprintf("%d", data.RestStressLevel), + }) + } + case "table": + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Date", "Stress Level", "Rest Stress Level"}) + for _, data := range stressData { + table.Append([]string{ + data.Date.Format("2006-01-02"), + fmt.Sprintf("%d", data.StressLevel), + fmt.Sprintf("%d", data.RestStressLevel), + }) + } + table.Render() + default: + return fmt.Errorf("unsupported output format: %s", outputFormat) } return nil @@ -227,10 +483,87 @@ func runBodyBattery(cmd *cobra.Command, args []string) error { return nil } - fmt.Println("Body Battery Data:") - for _, data := range bodyBatteryData { - fmt.Printf("- Date: %s, Level: %d, Charge: %d, Drain: %d\n", - data.Date.Format("2006-01-02"), data.BatteryLevel, data.Charge, data.Drain) + // Apply aggregation if requested + if healthAggregate != "" { + aggregatedBodyBattery := make(map[string]struct { + BatteryLevel int + Charge int + Drain int + Count int + }) + + for _, data := range bodyBatteryData { + key := "" + switch healthAggregate { + case "day": + key = data.Date.Format("2006-01-02") + case "week": + year, week := data.Date.ISOWeek() + key = fmt.Sprintf("%d-W%02d", year, week) + case "month": + key = data.Date.Format("2006-01") + case "year": + key = data.Date.Format("2006") + default: + return fmt.Errorf("unsupported aggregation period: %s", healthAggregate) + } + + entry := aggregatedBodyBattery[key] + entry.BatteryLevel += data.BatteryLevel + entry.Charge += data.Charge + entry.Drain += data.Drain + entry.Count++ + aggregatedBodyBattery[key] = entry + } + + // Convert aggregated data back to a slice for output + bodyBatteryData = []garmin.BodyBatteryData{} + for key, entry := range aggregatedBodyBattery { + bodyBatteryData = append(bodyBatteryData, garmin.BodyBatteryData{ + Date: parseAggregationKey(key, healthAggregate), // Helper to parse key back to date + BatteryLevel: entry.BatteryLevel / entry.Count, + Charge: entry.Charge / entry.Count, + Drain: entry.Drain / entry.Count, + }) + } + } + + outputFormat := viper.GetString("output") + + switch outputFormat { + case "json": + data, err := json.MarshalIndent(bodyBatteryData, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal Body Battery data to JSON: %w", err) + } + fmt.Println(string(data)) + case "csv": + writer := csv.NewWriter(os.Stdout) + defer writer.Flush() + + writer.Write([]string{"Date", "BatteryLevel", "Charge", "Drain"}) + for _, data := range bodyBatteryData { + writer.Write([]string{ + data.Date.Format("2006-01-02"), + fmt.Sprintf("%d", data.BatteryLevel), + fmt.Sprintf("%d", data.Charge), + fmt.Sprintf("%d", data.Drain), + }) + } + case "table": + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Date", "Level", "Charge", "Drain"}) + for _, data := range bodyBatteryData { + table.Append([]string{ + data.Date.Format("2006-01-02"), + fmt.Sprintf("%d", data.BatteryLevel), + fmt.Sprintf("%d", data.Charge), + fmt.Sprintf("%d", data.Drain), + }) + } + table.Render() + default: + return fmt.Errorf("unsupported output format: %s", outputFormat) } return nil diff --git a/cmd/garth/stats.go b/cmd/garth/stats.go index 0781c41..08e19c1 100644 --- a/cmd/garth/stats.go +++ b/cmd/garth/stats.go @@ -1,10 +1,15 @@ package main import ( + "encoding/csv" + "encoding/json" "fmt" + "os" "time" + "github.com/olekukonko/tablewriter" "github.com/spf13/cobra" + "github.com/spf13/viper" "garmin-connect/pkg/garmin" ) @@ -41,6 +46,7 @@ var ( statsMonth bool statsYear bool statsFrom string + statsAggregate string ) func init() { @@ -48,12 +54,15 @@ func init() { statsCmd.AddCommand(stepsCmd) stepsCmd.Flags().BoolVar(&statsMonth, "month", false, "Fetch data for the current month") + stepsCmd.Flags().StringVar(&statsAggregate, "aggregate", "", "Aggregate data by (day, week, month, year)") statsCmd.AddCommand(distanceCmd) distanceCmd.Flags().BoolVar(&statsYear, "year", false, "Fetch data for the current year") + distanceCmd.Flags().StringVar(&statsAggregate, "aggregate", "", "Aggregate data by (day, week, month, year)") statsCmd.AddCommand(caloriesCmd) caloriesCmd.Flags().StringVar(&statsFrom, "from", "", "Start date for data fetching (YYYY-MM-DD)") + caloriesCmd.Flags().StringVar(&statsAggregate, "aggregate", "", "Aggregate data by (day, week, month, year)") } func runSteps(cmd *cobra.Command, args []string) error { @@ -88,14 +97,108 @@ func runSteps(cmd *cobra.Command, args []string) error { return nil } - fmt.Println("Steps Data:") - for _, data := range stepsData { - fmt.Printf("- Date: %s, Steps: %d\n", data.Date.Format("2006-01-02"), data.Steps) + // Apply aggregation if requested + if statsAggregate != "" { + aggregatedSteps := make(map[string]struct { + Steps int + Count int + }) + + for _, data := range stepsData { + key := "" + switch statsAggregate { + case "day": + key = data.Date.Format("2006-01-02") + case "week": + year, week := data.Date.ISOWeek() + key = fmt.Sprintf("%d-W%02d", year, week) + case "month": + key = data.Date.Format("2006-01") + case "year": + key = data.Date.Format("2006") + default: + return fmt.Errorf("unsupported aggregation period: %s", statsAggregate) + } + + entry := aggregatedSteps[key] + entry.Steps += data.Steps + entry.Count++ + aggregatedSteps[key] = entry + } + + // Convert aggregated data back to a slice for output + stepsData = []garmin.StepsData{} + for key, entry := range aggregatedSteps { + stepsData = append(stepsData, garmin.StepsData{ + Date: parseAggregationKey(key, statsAggregate), // Helper to parse key back to date + Steps: entry.Steps / entry.Count, + }) + } + } + + outputFormat := viper.GetString("output") + + switch outputFormat { + case "json": + data, err := json.MarshalIndent(stepsData, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal steps data to JSON: %w", err) + } + fmt.Println(string(data)) + case "csv": + writer := csv.NewWriter(os.Stdout) + defer writer.Flush() + + writer.Write([]string{"Date", "Steps"}) + for _, data := range stepsData { + writer.Write([]string{ + data.Date.Format("2006-01-02"), + fmt.Sprintf("%d", data.Steps), + }) + } + case "table": + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Date", "Steps"}) + for _, data := range stepsData { + table.Append([]string{ + data.Date.Format("2006-01-02"), + fmt.Sprintf("%d", data.Steps), + }) + } + table.Render() + default: + return fmt.Errorf("unsupported output format: %s", outputFormat) } return nil } +// Helper function to parse aggregation key back to a time.Time object +func parseAggregationKey(key, aggregate string) time.Time { + switch aggregate { + case "day": + t, _ := time.Parse("2006-01-02", key) + return t + case "week": + year, _ := strconv.Atoi(key[:4]) + week, _ := strconv.Atoi(key[6:]) + t := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC) + // Find the first Monday of the year + for t.Weekday() != time.Monday { + t = t.AddDate(0, 0, 1) + } + // Add weeks + return t.AddDate(0, 0, (week-1)*7) + case "month": + t, _ := time.Parse("2006-01", key) + return t + case "year": + t, _ := time.Parse("2006", key) + return t + } + return time.Time{} +} + func runDistance(cmd *cobra.Command, args []string) error { garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable if err != nil { @@ -128,9 +231,77 @@ func runDistance(cmd *cobra.Command, args []string) error { return nil } - fmt.Println("Distance Data:") - for _, data := range distanceData { - fmt.Printf("- Date: %s, Distance: %.2f km\n", data.Date.Format("2006-01-02"), data.Distance/1000) + // Apply aggregation if requested + if statsAggregate != "" { + aggregatedDistance := make(map[string]struct { + Distance float64 + Count int + }) + + for _, data := range distanceData { + key := "" + switch statsAggregate { + case "day": + key = data.Date.Format("2006-01-02") + case "week": + year, week := data.Date.ISOWeek() + key = fmt.Sprintf("%d-W%02d", year, week) + case "month": + key = data.Date.Format("2006-01") + case "year": + key = data.Date.Format("2006") + default: + return fmt.Errorf("unsupported aggregation period: %s", statsAggregate) + } + + entry := aggregatedDistance[key] + entry.Distance += data.Distance + entry.Count++ + aggregatedDistance[key] = entry + } + + // Convert aggregated data back to a slice for output + distanceData = []garmin.DistanceData{} + for key, entry := range aggregatedDistance { + distanceData = append(distanceData, garmin.DistanceData{ + Date: parseAggregationKey(key, statsAggregate), // Helper to parse key back to date + Distance: entry.Distance / float64(entry.Count), + }) + } + } + + outputFormat := viper.GetString("output") + + switch outputFormat { + case "json": + data, err := json.MarshalIndent(distanceData, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal distance data to JSON: %w", err) + } + fmt.Println(string(data)) + case "csv": + writer := csv.NewWriter(os.Stdout) + defer writer.Flush() + + writer.Write([]string{"Date", "Distance(km)"}) + for _, data := range distanceData { + writer.Write([]string{ + data.Date.Format("2006-01-02"), + fmt.Sprintf("%.2f", data.Distance/1000), + }) + } + case "table": + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Date", "Distance (km)"}) + for _, data := range distanceData { + table.Append([]string{ + data.Date.Format("2006-01-02"), + fmt.Sprintf("%.2f", data.Distance/1000), + }) + } + table.Render() + default: + return fmt.Errorf("unsupported output format: %s", outputFormat) } return nil @@ -170,9 +341,77 @@ func runCalories(cmd *cobra.Command, args []string) error { return nil } - fmt.Println("Calories Data:") - for _, data := range caloriesData { - fmt.Printf("- Date: %s, Calories: %d\n", data.Date.Format("2006-01-02"), data.Calories) + // Apply aggregation if requested + if statsAggregate != "" { + aggregatedCalories := make(map[string]struct { + Calories int + Count int + }) + + for _, data := range caloriesData { + key := "" + switch statsAggregate { + case "day": + key = data.Date.Format("2006-01-02") + case "week": + year, week := data.Date.ISOWeek() + key = fmt.Sprintf("%d-W%02d", year, week) + case "month": + key = data.Date.Format("2006-01") + case "year": + key = data.Date.Format("2006") + default: + return fmt.Errorf("unsupported aggregation period: %s", statsAggregate) + } + + entry := aggregatedCalories[key] + entry.Calories += data.Calories + entry.Count++ + aggregatedCalories[key] = entry + } + + // Convert aggregated data back to a slice for output + caloriesData = []garmin.CaloriesData{} + for key, entry := range aggregatedCalories { + caloriesData = append(caloriesData, garmin.CaloriesData{ + Date: parseAggregationKey(key, statsAggregate), // Helper to parse key back to date + Calories: entry.Calories / entry.Count, + }) + } + } + + outputFormat := viper.GetString("output") + + switch outputFormat { + case "json": + data, err := json.MarshalIndent(caloriesData, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal calories data to JSON: %w", err) + } + fmt.Println(string(data)) + case "csv": + writer := csv.NewWriter(os.Stdout) + defer writer.Flush() + + writer.Write([]string{"Date", "Calories"}) + for _, data := range caloriesData { + writer.Write([]string{ + data.Date.Format("2006-01-02"), + fmt.Sprintf("%d", data.Calories), + }) + } + case "table": + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Date", "Calories"}) + for _, data := range caloriesData { + table.Append([]string{ + data.Date.Format("2006-01-02"), + fmt.Sprintf("%d", data.Calories), + }) + } + table.Render() + default: + return fmt.Errorf("unsupported output format: %s", outputFormat) } return nil diff --git a/go.mod b/go.mod index 817b3b5..4e64d3d 100644 --- a/go.mod +++ b/go.mod @@ -9,10 +9,18 @@ require ( ) require ( + github.com/fatih/color v1.15.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/olekukonko/errors v1.1.0 // indirect + github.com/olekukonko/ll v0.0.9 // indirect + github.com/olekukonko/tablewriter v1.0.9 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/rivo/uniseg v0.2.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect diff --git a/go.sum b/go.sum index b0fc449..1d449c4 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -17,10 +19,25 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/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/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= +github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= +github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= +github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8= +github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -45,6 +62,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= diff --git a/phase1.md b/phase1.md index fceba72..5db082d 100644 --- a/phase1.md +++ b/phase1.md @@ -204,7 +204,7 @@ type ActivityDetail struct { - [x] Enhanced activity listing with filters - [x] Activity detail fetching - [x] Search functionality -- [ ] Table formatting for activity lists +- [x] Table formatting for activity lists - [x] Activity download preparation (basic structure) - [x] Date range filtering - [x] Activity type filtering @@ -229,15 +229,15 @@ garth health bodybattery --yesterday **Tasks:** - [x] Implement all health data commands - [x] Add date range parsing utilities -- [ ] Create consistent output formatting -- [ ] Add data aggregation options +- [x] Create consistent output formatting +- [x] Add data aggregation options - [ ] Implement caching for expensive operations - [x] Error handling for missing data **Deliverables:** - [x] All health commands working - [x] Consistent date filtering across commands -- [ ] Proper data formatting and display +- [x] Proper data formatting and display #### 1B.4: Statistics Commands **Duration: 1 day** @@ -252,14 +252,14 @@ garth stats calories --from 2024-01-01 **Tasks:** - [x] Implement statistics commands - [x] Add aggregation periods (day, week, month, year) -- [ ] Create summary statistics +- [x] Create summary statistics - [ ] Add trend analysis -- [ ] Implement data export options +- [x] Implement data export options **Deliverables:** - [x] All stats commands working - [x] Multiple aggregation options -- [ ] Export functionality +- [x] Export functionality ---