diff --git a/cmd/garth/activities.go b/cmd/garth/activities.go index ff847dd..b095081 100644 --- a/cmd/garth/activities.go +++ b/cmd/garth/activities.go @@ -8,14 +8,14 @@ import ( "path/filepath" "strconv" "sync" - "sync/atomic" "time" + "github.com/olekukonko/tablewriter" "github.com/schollz/progressbar/v3" "github.com/spf13/cobra" "github.com/spf13/viper" - "garmin-connect/pkg/garmin" + "go-garth/pkg/garmin" ) var ( @@ -43,7 +43,7 @@ var ( downloadActivitiesCmd = &cobra.Command{ Use: "download [activityID]", Short: "Download activity data", - Args: cobra.RangeArgs(0, 1), RunE: runDownloadActivity, + Args: cobra.RangeArgs(0, 1), RunE: runDownloadActivity, } searchActivitiesCmd = &cobra.Command{ @@ -54,15 +54,15 @@ var ( } // Flags for listActivitiesCmd - activityLimit int - activityOffset int - activityType string - activityDateFrom string - activityDateTo string + activityLimit int + activityOffset int + activityType string + activityDateFrom string + activityDateTo string // Flags for downloadActivitiesCmd - downloadFormat string - outputDir string + downloadFormat string + outputDir string downloadOriginal bool downloadAll bool ) @@ -105,8 +105,8 @@ func runListActivities(cmd *cobra.Command, args []string) error { } opts := garmin.ActivityOptions{ - Limit: activityLimit, - Offset: activityOffset, + Limit: activityLimit, + Offset: activityOffset, ActivityType: activityType, } @@ -152,21 +152,21 @@ func runListActivities(cmd *cobra.Command, args []string) error { writer.Write([]string{ fmt.Sprintf("%d", activity.ActivityID), activity.ActivityName, - activity.ActivityType, - activity.Starttime.Format("2006-01-02 15:04:05"), + activity.ActivityType.TypeKey, + activity.StartTimeLocal.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)"}) + table.Header([]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"), + activity.ActivityType.TypeKey, + activity.StartTimeLocal.Format("2006-01-02 15:04:05"), fmt.Sprintf("%.2f", activity.Distance/1000), fmt.Sprintf("%.0f", activity.Duration), }) @@ -204,7 +204,7 @@ func runGetActivity(cmd *cobra.Command, args []string) error { fmt.Printf("Activity Details (ID: %d):\n", activityDetail.ActivityID) fmt.Printf(" Name: %s\n", activityDetail.ActivityName) fmt.Printf(" Type: %s\n", activityDetail.ActivityType) - fmt.Printf(" Date: %s\n", activityDetail.Starttime.Format("2006-01-02 15:04:05")) + fmt.Printf(" Date: %s\n", activityDetail.StartTimeLocal.Format("2006-01-02 15:04:05")) fmt.Printf(" Distance: %.2f km\n", activityDetail.Distance/1000) fmt.Printf(" Duration: %.0f s\n", activityDetail.Duration) fmt.Printf(" Description: %s\n", activityDetail.Description) @@ -213,6 +213,10 @@ func runGetActivity(cmd *cobra.Command, args []string) error { } func runDownloadActivity(cmd *cobra.Command, args []string) error { + var wg sync.WaitGroup + const concurrencyLimit = 5 // Limit concurrent downloads + sem := make(chan struct{}, concurrencyLimit) + garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable if err != nil { return fmt.Errorf("failed to create client: %w", err) @@ -292,7 +296,7 @@ func runDownloadActivity(cmd *cobra.Command, args []string) error { defer func() { <-sem }() if downloadFormat == "csv" { - activityDetail, err := garminClient.GetActivity(activity.ActivityID) + activityDetail, err := garminClient.GetActivity(int(activity.ActivityID)) if err != nil { fmt.Printf("Warning: Failed to get activity details for CSV export for activity %d: %v\n", activity.ActivityID, err) bar.Add(1) @@ -323,8 +327,8 @@ func runDownloadActivity(cmd *cobra.Command, args []string) error { writer.Write([]string{ fmt.Sprintf("%d", activityDetail.ActivityID), activityDetail.ActivityName, - activityDetail.ActivityType, - activityDetail.Starttime.Format("2006-01-02 15:04:05"), + activityDetail.ActivityType.TypeKey, + activityDetail.StartTimeLocal.Format("2006-01-02 15:04:05"), fmt.Sprintf("%.2f", activityDetail.Distance/1000), fmt.Sprintf("%.0f", activityDetail.Duration), activityDetail.Description, @@ -332,35 +336,39 @@ func runDownloadActivity(cmd *cobra.Command, args []string) error { fmt.Printf("Activity %d summary exported to %s\n", activity.ActivityID, outputPath) } else { + filename := fmt.Sprintf("%d.%s", activity.ActivityID, downloadFormat) + if downloadOriginal { + filename = fmt.Sprintf("%d_original.fit", activity.ActivityID) // Assuming original is .fit + } + outputPath := filepath.Join(outputDir, filename) + + // Check if file already exists + if _, err := os.Stat(outputPath); err == nil { + fmt.Printf("Skipping activity %d: file already exists at %s\n", activity.ActivityID, outputPath) + bar.Add(1) + return + } else if !os.IsNotExist(err) { + fmt.Printf("Warning: Failed to check existence of file %s for activity %d: %v\n", outputPath, activity.ActivityID, err) + bar.Add(1) + return + } + opts := garmin.DownloadOptions{ Format: downloadFormat, OutputDir: outputDir, Original: downloadOriginal, + Filename: filename, // Pass filename to opts } - outputPath = filepath.Join(outputDir, filename) - } - - // Check if file already exists - if _, err := os.Stat(outputPath); err == nil { - fmt.Printf("Skipping activity %d: file already exists at %s\n", activity.ActivityID, outputPath) - bar.Add(1) - return - } else if !os.IsNotExist(err) { - fmt.Printf("Warning: Failed to check existence of file %s for activity %d: %v\n", outputPath, activity.ActivityID, err) - bar.Add(1) - return - } - - fmt.Printf("Downloading activity %d in %s format to %s...\n", activity.ActivityID, downloadFormat, outputDir) - if err := garminClient.DownloadActivity(activity.ActivityID, opts); err != nil { - fmt.Printf("Warning: Failed to download activity %d: %v\n", activity.ActivityID, err) - bar.Add(1) - return - } - - fmt.Printf("Activity %d downloaded successfully.\n", activity.ActivityID) } + fmt.Printf("Downloading activity %d in %s format to %s...\n", activity.ActivityID, downloadFormat, outputPath) + if err := garminClient.DownloadActivity(int(activity.ActivityID), opts); err != nil { + fmt.Printf("Warning: Failed to download activity %d: %v\n", activity.ActivityID, err) + bar.Add(1) + return + } + fmt.Printf("Activity %d downloaded successfully.\n", activity.ActivityID) + } bar.Add(1) }(activity) } @@ -401,8 +409,8 @@ func runSearchActivities(cmd *cobra.Command, args []string) error { fmt.Printf("Activities matching '%s':\n", query) for _, activity := range activities { fmt.Printf("- ID: %d, Name: %s, Type: %s, Date: %s\n", - activity.ActivityID, activity.ActivityName, activity.ActivityType, - activity.Starttime.Format("2006-01-02")) + activity.ActivityID, activity.ActivityName, activity.ActivityType.TypeKey, + activity.StartTimeLocal.Format("2006-01-02")) } return nil diff --git a/cmd/garth/auth.go b/cmd/garth/auth.go index 9224266..e589070 100644 --- a/cmd/garth/auth.go +++ b/cmd/garth/auth.go @@ -2,13 +2,11 @@ package main import ( "fmt" - "log" "os" "golang.org/x/term" - "garmin-connect/internal/auth/credentials" - "garmin-connect/pkg/garmin" + "go-garth/pkg/garmin" "github.com/spf13/cobra" ) diff --git a/cmd/garth/cmd/activities.go b/cmd/garth/cmd/activities.go index 6c6d349..19e2a68 100644 --- a/cmd/garth/cmd/activities.go +++ b/cmd/garth/cmd/activities.go @@ -5,8 +5,8 @@ import ( "log" "time" - "garmin-connect/internal/auth/credentials" - "garmin-connect/pkg/garmin" + "go-garth/internal/auth/credentials" + "go-garth/pkg/garmin" "github.com/spf13/cobra" ) @@ -34,7 +34,10 @@ var activitiesCmd = &cobra.Command{ log.Fatalf("No existing session found. Please run 'garth login' first.") } - activities, err := garminClient.GetActivities(5) + opts := garmin.ActivityOptions{ + Limit: 5, + } + activities, err := garminClient.ListActivities(opts) if err != nil { log.Fatalf("Failed to get activities: %v", err) } diff --git a/cmd/garth/cmd/data.go b/cmd/garth/cmd/data.go index 9642e25..09a67dd 100644 --- a/cmd/garth/cmd/data.go +++ b/cmd/garth/cmd/data.go @@ -7,8 +7,8 @@ import ( "os" "time" - "garmin-connect/internal/auth/credentials" - "garmin-connect/pkg/garmin" + "go-garth/internal/auth/credentials" + "go-garth/pkg/garmin" "github.com/spf13/cobra" ) @@ -58,13 +58,13 @@ var dataCmd = &cobra.Command{ switch dataType { case "bodybattery": - result, err = garminClient.GetBodyBattery(endDate) + result, err = garminClient.GetBodyBatteryData(endDate, endDate) case "sleep": - result, err = garminClient.GetSleep(endDate) + result, err = garminClient.GetSleepData(endDate, endDate) case "hrv": - result, err = garminClient.GetHRV(endDate) - case "weight": - result, err = garminClient.GetWeight(endDate) + result, err = garminClient.GetHrvData(dataDays) + // case "weight": + // result, err = garminClient.GetWeight(endDate) default: log.Fatalf("Unknown data type: %s", dataType) } diff --git a/cmd/garth/cmd/stats.go b/cmd/garth/cmd/stats.go index 06e32a9..afea464 100644 --- a/cmd/garth/cmd/stats.go +++ b/cmd/garth/cmd/stats.go @@ -4,8 +4,8 @@ import ( "log" "time" - "garmin-connect/internal/auth/credentials" - "garmin-connect/pkg/garmin" + "go-garth/internal/auth/credentials" + "go-garth/pkg/garmin" "github.com/spf13/cobra" ) diff --git a/cmd/garth/cmd/tokens.go b/cmd/garth/cmd/tokens.go index 025fedc..700dd77 100644 --- a/cmd/garth/cmd/tokens.go +++ b/cmd/garth/cmd/tokens.go @@ -5,8 +5,8 @@ import ( "fmt" "log" - "garmin-connect/internal/auth/credentials" - "garmin-connect/pkg/garmin" + "go-garth/internal/auth/credentials" + "go-garth/pkg/garmin" "github.com/spf13/cobra" ) diff --git a/cmd/garth/health.go b/cmd/garth/health.go index 079c8d1..40b7bdf 100644 --- a/cmd/garth/health.go +++ b/cmd/garth/health.go @@ -5,13 +5,16 @@ import ( "encoding/json" "fmt" "os" + "strconv" "time" "github.com/olekukonko/tablewriter" "github.com/spf13/cobra" "github.com/spf13/viper" - "garmin-connect/pkg/garmin" + types "go-garth/internal/types" + "go-garth/internal/utils" + "go-garth/pkg/garmin" ) var ( @@ -49,11 +52,23 @@ var ( RunE: runBodyBattery, } - // Flags for health commands - healthDateFrom string - healthDateTo string - healthDays int - healthWeek bool + vo2maxCmd = &cobra.Command{ + Use: "vo2max", + Short: "Get VO2 Max data", + Long: `Fetch VO2 Max data for a specified date range.`, + RunE: runVO2Max, + } + + hrZonesCmd = &cobra.Command{ + Use: "hr-zones", + Short: "Get Heart Rate Zones data", + Long: `Fetch Heart Rate Zones data.`, + RunE: runHRZones, + } + healthDateFrom string + healthDateTo string + healthDays int + healthWeek bool healthYesterday bool healthAggregate string ) @@ -77,6 +92,42 @@ func init() { 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)") + + // VO2 Max Command + vo2maxCmd = &cobra.Command{ + Use: "vo2max", + Short: "Get VO2 Max data", + Long: `Fetch VO2 Max data for a specified date range.`, + RunE: runVO2Max, + } + + healthCmd.AddCommand(vo2maxCmd) + vo2maxCmd.Flags().StringVar(&healthDateFrom, "from", "", "Start date for data fetching (YYYY-MM-DD)") + vo2maxCmd.Flags().StringVar(&healthDateTo, "to", "", "End date for data fetching (YYYY-MM-DD)") + vo2maxCmd.Flags().StringVar(&healthAggregate, "aggregate", "", "Aggregate data by (day, week, month, year)") + + // Heart Rate Zones Command + hrZonesCmd = &cobra.Command{ + Use: "hr-zones", + Short: "Get Heart Rate Zones data", + Long: `Fetch Heart Rate Zones data.`, + RunE: runHRZones, + } + + healthCmd.AddCommand(hrZonesCmd) + + // Wellness Command + wellnessCmd = &cobra.Command{ + Use: "wellness", + Short: "Get comprehensive wellness data", + Long: `Fetch comprehensive wellness data including body composition and resting heart rate trends.`, + RunE: runWellness, + } + + healthCmd.AddCommand(wellnessCmd) + wellnessCmd.Flags().StringVar(&healthDateFrom, "from", "", "Start date for data fetching (YYYY-MM-DD)") + wellnessCmd.Flags().StringVar(&healthDateTo, "to", "", "End date for data fetching (YYYY-MM-DD)") + wellnessCmd.Flags().StringVar(&healthAggregate, "aggregate", "", "Aggregate data by (day, week, month, year)") } func runSleep(cmd *cobra.Command, args []string) error { @@ -152,12 +203,16 @@ func runSleep(cmd *cobra.Command, args []string) error { } // Convert aggregated data back to a slice for output - sleepData = []garmin.SleepData{} + sleepData = []types.SleepData{} for key, entry := range aggregatedSleep { - sleepData = append(sleepData, garmin.SleepData{ - Date: parseAggregationKey(key, healthAggregate), // Helper to parse key back to date + sleepData = append(sleepData, types.SleepData{ + Date: utils.ParseAggregationKey(key, healthAggregate), TotalSleepSeconds: entry.TotalSleepSeconds / entry.Count, SleepScore: entry.SleepScore / entry.Count, + DeepSleepSeconds: 0, + LightSleepSeconds: 0, + RemSleepSeconds: 0, + AwakeSleepSeconds: 0, }) } } @@ -189,7 +244,7 @@ func runSleep(cmd *cobra.Command, args []string) error { } case "table": table := tablewriter.NewWriter(os.Stdout) - table.SetHeader([]string{"Date", "Score", "Total Sleep", "Deep", "Light", "REM", "Awake"}) + table.Header([]string{"Date", "Score", "Total Sleep", "Deep", "Light", "REM", "Awake"}) for _, data := range sleepData { table.Append([]string{ data.Date.Format("2006-01-02"), @@ -209,32 +264,6 @@ func runSleep(cmd *cobra.Command, args []string) error { 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 { @@ -291,10 +320,10 @@ func runHrv(cmd *cobra.Command, args []string) error { } // Convert aggregated data back to a slice for output - hrvData = []garmin.HrvData{} + hrvData = []types.HrvData{} for key, entry := range aggregatedHrv { - hrvData = append(hrvData, garmin.HrvData{ - Date: parseAggregationKey(key, healthAggregate), // Helper to parse key back to date + hrvData = append(hrvData, types.HrvData{ + Date: utils.ParseAggregationKey(key, healthAggregate), HrvValue: entry.HrvValue / float64(entry.Count), }) } @@ -322,7 +351,7 @@ func runHrv(cmd *cobra.Command, args []string) error { } case "table": table := tablewriter.NewWriter(os.Stdout) - table.SetHeader([]string{"Date", "HRV Value"}) + table.Header([]string{"Date", "HRV Value"}) for _, data := range hrvData { table.Append([]string{ data.Date.Format("2006-01-02"), @@ -403,10 +432,10 @@ func runStress(cmd *cobra.Command, args []string) error { } // Convert aggregated data back to a slice for output - stressData = []garmin.StressData{} + stressData = []types.StressData{} for key, entry := range aggregatedStress { - stressData = append(stressData, garmin.StressData{ - Date: parseAggregationKey(key, healthAggregate), // Helper to parse key back to date + stressData = append(stressData, types.StressData{ + Date: utils.ParseAggregationKey(key, healthAggregate), StressLevel: entry.StressLevel / entry.Count, RestStressLevel: entry.RestStressLevel / entry.Count, }) @@ -436,7 +465,7 @@ func runStress(cmd *cobra.Command, args []string) error { } case "table": table := tablewriter.NewWriter(os.Stdout) - table.SetHeader([]string{"Date", "Stress Level", "Rest Stress Level"}) + table.Header([]string{"Date", "Stress Level", "Rest Stress Level"}) for _, data := range stressData { table.Append([]string{ data.Date.Format("2006-01-02"), @@ -517,10 +546,10 @@ func runBodyBattery(cmd *cobra.Command, args []string) error { } // Convert aggregated data back to a slice for output - bodyBatteryData = []garmin.BodyBatteryData{} + bodyBatteryData = []types.BodyBatteryData{} for key, entry := range aggregatedBodyBattery { - bodyBatteryData = append(bodyBatteryData, garmin.BodyBatteryData{ - Date: parseAggregationKey(key, healthAggregate), // Helper to parse key back to date + bodyBatteryData = append(bodyBatteryData, types.BodyBatteryData{ + Date: utils.ParseAggregationKey(key, healthAggregate), BatteryLevel: entry.BatteryLevel / entry.Count, Charge: entry.Charge / entry.Count, Drain: entry.Drain / entry.Count, @@ -552,7 +581,7 @@ func runBodyBattery(cmd *cobra.Command, args []string) error { } case "table": table := tablewriter.NewWriter(os.Stdout) - table.SetHeader([]string{"Date", "Level", "Charge", "Drain"}) + table.Header([]string{"Date", "Battery Level", "Charge", "Drain"}) for _, data := range bodyBatteryData { table.Append([]string{ data.Date.Format("2006-01-02"), @@ -568,3 +597,205 @@ func runBodyBattery(cmd *cobra.Command, args []string) error { return nil } + +func runVO2Max(cmd *cobra.Command, args []string) error { + garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + sessionFile := "garmin_session.json" // TODO: Make session file configurable + if err := garminClient.LoadSession(sessionFile); err != nil { + return fmt.Errorf("not logged in: %w", err) + } + + var startDate, endDate time.Time + + if healthDateFrom != "" { + startDate, err = time.Parse("2006-01-02", healthDateFrom) + if err != nil { + return fmt.Errorf("invalid date format for --from: %w", err) + } + } else { + startDate = time.Now().AddDate(0, 0, -7) // Default to last 7 days + } + + if healthDateTo != "" { + endDate = time.Now() // Default to today + parsedDate, err := time.Parse("2006-01-02", healthDateTo) + if err != nil { + return fmt.Errorf("invalid date format for --to: %w", err) + } + endDate = parsedDate + } + + vo2maxData, err := garminClient.GetVO2MaxData(startDate, endDate) + if err != nil { + return fmt.Errorf("failed to get VO2 Max data: %w", err) + } + + if len(vo2maxData) == 0 { + fmt.Println("No VO2 Max data found.") + return nil + } + + // Apply aggregation if requested + if healthAggregate != "" { + aggregatedVO2Max := make(map[string]struct { + VO2MaxRunning float64 + VO2MaxCycling float64 + Count int + }) + + for _, data := range vo2maxData { + 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 := aggregatedVO2Max[key] + entry.VO2MaxRunning += data.VO2MaxRunning + entry.VO2MaxCycling += data.VO2MaxCycling + entry.Count++ + aggregatedVO2Max[key] = entry + } + + // Convert aggregated data back to a slice for output + vo2maxData = []types.VO2MaxData{} + for key, entry := range aggregatedVO2Max { + vo2maxData = append(vo2maxData, types.VO2MaxData{ + Date: utils.ParseAggregationKey(key, healthAggregate), + VO2MaxRunning: entry.VO2MaxRunning / float64(entry.Count), + VO2MaxCycling: entry.VO2MaxCycling / float64(entry.Count), + }) + } + } + + outputFormat := viper.GetString("output") + + switch outputFormat { + case "json": + data, err := json.MarshalIndent(vo2maxData, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal VO2 Max data to JSON: %w", err) + } + fmt.Println(string(data)) + case "csv": + writer := csv.NewWriter(os.Stdout) + defer writer.Flush() + + writer.Write([]string{"Date", "VO2MaxRunning", "VO2MaxCycling"}) + for _, data := range vo2maxData { + writer.Write([]string{ + data.Date.Format("2006-01-02"), + fmt.Sprintf("%.2f", data.VO2MaxRunning), + fmt.Sprintf("%.2f", data.VO2MaxCycling), + }) + } + case "table": + table := tablewriter.NewWriter(os.Stdout) + table.Header([]string{"Date", "VO2 Max Running", "VO2 Max Cycling"}) + for _, data := range vo2maxData { + table.Append([]string{ + data.Date.Format("2006-01-02"), + fmt.Sprintf("%.2f", data.VO2MaxRunning), + fmt.Sprintf("%.2f", data.VO2MaxCycling), + }) + } + table.Render() + default: + return fmt.Errorf("unsupported output format: %s", outputFormat) + } + + return nil +} + +func runHRZones(cmd *cobra.Command, args []string) error { + garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + sessionFile := "garmin_session.json" // TODO: Make session file configurable + if err := garminClient.LoadSession(sessionFile); err != nil { + return fmt.Errorf("not logged in: %w", err) + } + + hrZonesData, err := garminClient.GetHeartRateZones() + if err != nil { + return fmt.Errorf("failed to get Heart Rate Zones data: %w", err) + } + + if hrZonesData == nil { + fmt.Println("No Heart Rate Zones data found.") + return nil + } + + outputFormat := viper.GetString("output") + + switch outputFormat { + case "json": + data, err := json.MarshalIndent(hrZonesData, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal Heart Rate Zones data to JSON: %w", err) + } + fmt.Println(string(data)) + case "csv": + writer := csv.NewWriter(os.Stdout) + defer writer.Flush() + + writer.Write([]string{"Zone", "MinBPM", "MaxBPM", "Name"}) + for _, zone := range hrZonesData.Zones { + writer.Write([]string{ + strconv.Itoa(zone.Zone), + strconv.Itoa(zone.MinBPM), + strconv.Itoa(zone.MaxBPM), + zone.Name, + }) + } + case "table": + table := tablewriter.NewWriter(os.Stdout) + table.Header([]string{"Resting HR", "Max HR", "Lactate Threshold", "Updated At"}) + table.Append([]string{ + strconv.Itoa(hrZonesData.RestingHR), + strconv.Itoa(hrZonesData.MaxHR), + strconv.Itoa(hrZonesData.LactateThreshold), + hrZonesData.UpdatedAt.Format("2006-01-02 15:04:05"), + }) + table.Render() + + fmt.Println() + + zonesTable := tablewriter.NewWriter(os.Stdout) + zonesTable.Header([]string{"Zone", "Min BPM", "Max BPM", "Name"}) + for _, zone := range hrZonesData.Zones { + zonesTable.Append([]string{ + strconv.Itoa(zone.Zone), + strconv.Itoa(zone.MinBPM), + strconv.Itoa(zone.MaxBPM), + zone.Name, + }) + } + zonesTable.Render() + default: + return fmt.Errorf("unsupported output format: %s", outputFormat) + } + + return nil +} + +var wellnessCmd *cobra.Command + +func runWellness(cmd *cobra.Command, args []string) error { + return fmt.Errorf("not implemented") +} diff --git a/cmd/garth/stats.go b/cmd/garth/stats.go index 08e19c1..eb6736b 100644 --- a/cmd/garth/stats.go +++ b/cmd/garth/stats.go @@ -11,194 +11,17 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" - "garmin-connect/pkg/garmin" + types "go-garth/internal/types" + "go-garth/internal/utils" + "go-garth/pkg/garmin" ) var ( - statsCmd = &cobra.Command{ - Use: "stats", - Short: "Manage Garmin Connect statistics", - Long: `Provides commands to fetch various statistics like steps, distance, and calories.`, - } - - stepsCmd = &cobra.Command{ - Use: "steps", - Short: "Get steps statistics", - Long: `Fetch steps statistics for a specified period.`, - RunE: runSteps, - } - - distanceCmd = &cobra.Command{ - Use: "distance", - Short: "Get distance statistics", - Long: `Fetch distance statistics for a specified period.`, - RunE: runDistance, - } - - caloriesCmd = &cobra.Command{ - Use: "calories", - Short: "Get calories statistics", - Long: `Fetch calories statistics for a specified period.`, - RunE: runCalories, - } - - // Flags for stats commands - statsMonth bool - statsYear bool - statsFrom string + statsYear bool statsAggregate string + statsFrom string ) -func init() { - rootCmd.AddCommand(statsCmd) - - 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 { - garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable - if err != nil { - return fmt.Errorf("failed to create client: %w", err) - } - - sessionFile := "garmin_session.json" // TODO: Make session file configurable - if err := garminClient.LoadSession(sessionFile); err != nil { - return fmt.Errorf("not logged in: %w", err) - } - - var startDate, endDate time.Time - if statsMonth { - now := time.Now() - startDate = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) - endDate = startDate.AddDate(0, 1, -1) // Last day of the month - } else { - // Default to today if no specific range or month is given - startDate = time.Now() - endDate = time.Now() - } - - stepsData, err := garminClient.GetStepsData(startDate, endDate) - if err != nil { - return fmt.Errorf("failed to get steps data: %w", err) - } - - if len(stepsData) == 0 { - fmt.Println("No steps data found.") - return nil - } - - // 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 { @@ -261,10 +84,10 @@ func runDistance(cmd *cobra.Command, args []string) error { } // Convert aggregated data back to a slice for output - distanceData = []garmin.DistanceData{} + distanceData = []types.DistanceData{} for key, entry := range aggregatedDistance { - distanceData = append(distanceData, garmin.DistanceData{ - Date: parseAggregationKey(key, statsAggregate), // Helper to parse key back to date + distanceData = append(distanceData, types.DistanceData{ + Date: utils.ParseAggregationKey(key, statsAggregate), // Helper to parse key back to date Distance: entry.Distance / float64(entry.Count), }) } @@ -292,7 +115,7 @@ func runDistance(cmd *cobra.Command, args []string) error { } case "table": table := tablewriter.NewWriter(os.Stdout) - table.SetHeader([]string{"Date", "Distance (km)"}) + table.Header([]string{"Date", "Distance (km)"}) for _, data := range distanceData { table.Append([]string{ data.Date.Format("2006-01-02"), @@ -371,10 +194,10 @@ func runCalories(cmd *cobra.Command, args []string) error { } // Convert aggregated data back to a slice for output - caloriesData = []garmin.CaloriesData{} + caloriesData = []types.CaloriesData{} for key, entry := range aggregatedCalories { - caloriesData = append(caloriesData, garmin.CaloriesData{ - Date: parseAggregationKey(key, statsAggregate), // Helper to parse key back to date + caloriesData = append(caloriesData, types.CaloriesData{ + Date: utils.ParseAggregationKey(key, statsAggregate), // Helper to parse key back to date Calories: entry.Calories / entry.Count, }) } @@ -402,7 +225,7 @@ func runCalories(cmd *cobra.Command, args []string) error { } case "table": table := tablewriter.NewWriter(os.Stdout) - table.SetHeader([]string{"Date", "Calories"}) + table.Header([]string{"Date", "Calories"}) for _, data := range caloriesData { table.Append([]string{ data.Date.Format("2006-01-02"), diff --git a/garmin-connect b/garmin-connect deleted file mode 100755 index 40c93f6..0000000 Binary files a/garmin-connect and /dev/null differ diff --git a/go.mod b/go.mod index a69c198..f2488f0 100644 --- a/go.mod +++ b/go.mod @@ -4,34 +4,35 @@ go 1.24.2 require ( github.com/joho/godotenv v1.5.1 + github.com/olekukonko/tablewriter v1.0.9 + github.com/schollz/progressbar/v3 v3.18.0 github.com/spf13/cobra v1.10.1 github.com/spf13/viper v1.21.0 + golang.org/x/term v0.28.0 ) require ( - github.com/fatih/color v1.15.0 // indirect + github.com/fatih/color v1.18.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-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect + github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // 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/olekukonko/ll v0.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/schollz/progressbar/v3 v3.18.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/term v0.28.0 // indirect + golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index cf07eb7..4c581e3 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,10 @@ +github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= +github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= 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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 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= @@ -19,27 +21,26 @@ 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-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +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/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= 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/ll v0.1.1 h1:9Dfeed5/Mgaxb9lHRAftLK9pVfYETvHn+If6lywVhJc= +github.com/olekukonko/ll v0.1.1/go.mod h1:2dJo+hYZcJMLMbKwHEWvxCUbAOLc/CXWS9noET22Mdo= 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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -69,10 +70,9 @@ 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/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= diff --git a/internal/api/client/auth_test.go b/internal/api/client/auth_test.go index 26da5c4..7f4255a 100644 --- a/internal/api/client/auth_test.go +++ b/internal/api/client/auth_test.go @@ -3,8 +3,8 @@ package client_test import ( "testing" - "garmin-connect/internal/api/client" - "garmin-connect/internal/auth/credentials" + "go-garth/internal/api/client" + "go-garth/internal/auth/credentials" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/api/client/client.go b/internal/api/client/client.go index e56cfe1..2f24d9a 100644 --- a/internal/api/client/client.go +++ b/internal/api/client/client.go @@ -14,9 +14,9 @@ import ( "strings" "time" - "garmin-connect/internal/errors" - "garmin-connect/internal/auth/sso" - "garmin-connect/internal/types" + "go-garth/internal/errors" + "go-garth/internal/auth/sso" + "go-garth/internal/types" ) // Client represents the Garmin Connect API client @@ -423,6 +423,244 @@ func (c *Client) GetActivities(limit int) ([]types.Activity, error) { return activities, nil } +func (c *Client) GetSleepData(startDate, endDate time.Time) ([]types.SleepData, error) { + // TODO: Implement GetSleepData + return nil, fmt.Errorf("GetSleepData not implemented") +} + +// GetHrvData retrieves HRV data for a specified number of days +func (c *Client) GetHrvData(days int) ([]types.HrvData, error) { + // TODO: Implement GetHrvData + return nil, fmt.Errorf("GetHrvData not implemented") +} + +// GetStressData retrieves stress data +func (c *Client) GetStressData(startDate, endDate time.Time) ([]types.StressData, error) { + // TODO: Implement GetStressData + return nil, fmt.Errorf("GetStressData not implemented") +} + +// GetBodyBatteryData retrieves Body Battery data +func (c *Client) GetBodyBatteryData(startDate, endDate time.Time) ([]types.BodyBatteryData, error) { + // TODO: Implement GetBodyBatteryData + return nil, fmt.Errorf("GetBodyBatteryData not implemented") +} + +// GetStepsData retrieves steps data for a specified date range +func (c *Client) GetStepsData(startDate, endDate time.Time) ([]types.StepsData, error) { + // TODO: Implement GetStepsData + return nil, fmt.Errorf("GetStepsData not implemented") +} + +// GetDistanceData retrieves distance data for a specified date range +func (c *Client) GetDistanceData(startDate, endDate time.Time) ([]types.DistanceData, error) { + // TODO: Implement GetDistanceData + return nil, fmt.Errorf("GetDistanceData not implemented") +} + +// GetCaloriesData retrieves calories data for a specified date range +func (c *Client) GetCaloriesData(startDate, endDate time.Time) ([]types.CaloriesData, error) { + // TODO: Implement GetCaloriesData + return nil, fmt.Errorf("GetCaloriesData not implemented") +} + + +func (c *Client) GetVO2MaxData(startDate, endDate time.Time) ([]types.VO2MaxData, error) { + scheme := "https" + if strings.HasPrefix(c.Domain, "127.0.0.1") { + scheme = "http" + } + + params := url.Values{} + params.Add("startDate", startDate.Format("2006-01-02")) + params.Add("endDate", endDate.Format("2006-01-02")) + + vo2MaxURL := fmt.Sprintf("%s://connectapi.%s/wellness-service/wellness/daily/vo2max?%s", scheme, c.Domain, params.Encode()) + + req, err := http.NewRequest("GET", vo2MaxURL, nil) + if err != nil { + return nil, &errors.APIError{ + GarthHTTPError: errors.GarthHTTPError{ + GarthError: errors.GarthError{ + Message: "Failed to create VO2 max request", + Cause: err, + }, + }, + } + } + + req.Header.Set("Authorization", c.AuthToken) + req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, &errors.APIError{ + GarthHTTPError: errors.GarthHTTPError{ + GarthError: errors.GarthError{ + Message: "Failed to get VO2 max data", + Cause: err, + }, + }, + } + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, &errors.APIError{ + GarthHTTPError: errors.GarthHTTPError{ + StatusCode: resp.StatusCode, + Response: string(body), + GarthError: errors.GarthError{ + Message: "VO2 max request failed", + }, + }, + } + } + + var vo2MaxData []types.VO2MaxData + if err := json.NewDecoder(resp.Body).Decode(&vo2MaxData); err != nil { + return nil, &errors.IOError{ + GarthError: errors.GarthError{ + Message: "Failed to parse VO2 max data", + Cause: err, + }, + } + } + + return vo2MaxData, nil +} + +// GetHeartRateZones retrieves heart rate zone data +func (c *Client) GetHeartRateZones() (*types.HeartRateZones, error) { + scheme := "https" + if strings.HasPrefix(c.Domain, "127.0.0.1") { + scheme = "http" + } + + hrzURL := fmt.Sprintf("%s://connectapi.%s/userprofile-service/userprofile/heartRateZones", scheme, c.Domain) + + req, err := http.NewRequest("GET", hrzURL, nil) + if err != nil { + return nil, &errors.APIError{ + GarthHTTPError: errors.GarthHTTPError{ + GarthError: errors.GarthError{ + Message: "Failed to create HR zones request", + Cause: err, + }, + }, + } + } + + req.Header.Set("Authorization", c.AuthToken) + req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, &errors.APIError{ + GarthHTTPError: errors.GarthHTTPError{ + GarthError: errors.GarthError{ + Message: "Failed to get HR zones data", + Cause: err, + }, + }, + } + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, &errors.APIError{ + GarthHTTPError: errors.GarthHTTPError{ + StatusCode: resp.StatusCode, + Response: string(body), + GarthError: errors.GarthError{ + Message: "HR zones request failed", + }, + }, + } + } + + var hrZones types.HeartRateZones + if err := json.NewDecoder(resp.Body).Decode(&hrZones); err != nil { + return nil, &errors.IOError{ + GarthError: errors.GarthError{ + Message: "Failed to parse HR zones data", + Cause: err, + }, + } + } + + return &hrZones, nil +} + +// GetWellnessData retrieves comprehensive wellness data for a specified date range +func (c *Client) GetWellnessData(startDate, endDate time.Time) ([]types.WellnessData, error) { + scheme := "https" + if strings.HasPrefix(c.Domain, "127.0.0.1") { + scheme = "http" + } + + params := url.Values{} + params.Add("startDate", startDate.Format("2006-01-02")) + params.Add("endDate", endDate.Format("2006-01-02")) + + wellnessURL := fmt.Sprintf("%s://connectapi.%s/wellness-service/wellness/daily/wellness?%s", scheme, c.Domain, params.Encode()) + + req, err := http.NewRequest("GET", wellnessURL, nil) + if err != nil { + return nil, &errors.APIError{ + GarthHTTPError: errors.GarthHTTPError{ + GarthError: errors.GarthError{ + Message: "Failed to create wellness data request", + Cause: err, + }, + }, + } + } + + req.Header.Set("Authorization", c.AuthToken) + req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, &errors.APIError{ + GarthHTTPError: errors.GarthHTTPError{ + GarthError: errors.GarthError{ + Message: "Failed to get wellness data", + Cause: err, + }, + }, + } + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, &errors.APIError{ + GarthHTTPError: errors.GarthHTTPError{ + StatusCode: resp.StatusCode, + Response: string(body), + GarthError: errors.GarthError{ + Message: "Wellness data request failed", + }, + }, + } + } + + var wellnessData []types.WellnessData + if err := json.NewDecoder(resp.Body).Decode(&wellnessData); err != nil { + return nil, &errors.IOError{ + GarthError: errors.GarthError{ + Message: "Failed to parse wellness data", + Cause: err, + }, + } + } + + return wellnessData, nil +} + // SaveSession saves the current session to a file func (c *Client) SaveSession(filename string) error { session := types.SessionData{ @@ -480,4 +718,10 @@ func (c *Client) LoadSession(filename string) error { c.AuthToken = session.AuthToken return nil +} + +// RefreshSession refreshes the authentication tokens +func (c *Client) RefreshSession() error { + // TODO: Implement token refresh logic + return fmt.Errorf("RefreshSession not implemented") } \ No newline at end of file diff --git a/internal/api/client/client_test.go b/internal/api/client/client_test.go index 8e4c3c0..5252add 100644 --- a/internal/api/client/client_test.go +++ b/internal/api/client/client_test.go @@ -7,12 +7,12 @@ import ( "testing" "time" - "garmin-connect/internal/testutils" + "go-garth/internal/testutils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "garmin-connect/internal/api/client" + "go-garth/internal/api/client" ) func TestClient_GetUserProfile(t *testing.T) { diff --git a/internal/auth/oauth/oauth.go b/internal/auth/oauth/oauth.go index e9bf1f5..a7670dd 100644 --- a/internal/auth/oauth/oauth.go +++ b/internal/auth/oauth/oauth.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "garmin-connect/internal/types" - "garmin-connect/internal/utils" + "go-garth/internal/types" + "go-garth/internal/utils" ) // GetOAuth1Token retrieves an OAuth1 token using the provided ticket diff --git a/internal/auth/sso/sso.go b/internal/auth/sso/sso.go index b120c5b..88fba49 100644 --- a/internal/auth/sso/sso.go +++ b/internal/auth/sso/sso.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "garmin-connect/internal/auth/oauth" - "garmin-connect/internal/types" + "go-garth/internal/auth/oauth" + "go-garth/internal/types" ) var ( diff --git a/internal/data/base.go b/internal/data/base.go index faf0da8..4dc4276 100644 --- a/internal/data/base.go +++ b/internal/data/base.go @@ -5,8 +5,8 @@ import ( "sync" "time" - "garmin-connect/internal/api/client" - "garmin-connect/internal/utils" + "go-garth/internal/api/client" + "go-garth/internal/utils" ) // Data defines the interface for Garmin Connect data types. diff --git a/internal/data/base_test.go b/internal/data/base_test.go index 6c16548..01b02f4 100644 --- a/internal/data/base_test.go +++ b/internal/data/base_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "garmin-connect/internal/api/client" + "go-garth/internal/api/client" "github.com/stretchr/testify/assert" ) diff --git a/internal/data/body_battery.go b/internal/data/body_battery.go index 1c332cb..5ae0f1d 100644 --- a/internal/data/body_battery.go +++ b/internal/data/body_battery.go @@ -6,7 +6,7 @@ import ( "sort" "time" - "garmin-connect/internal/api/client" + "go-garth/internal/api/client" ) // DailyBodyBatteryStress represents complete daily Body Battery and stress data diff --git a/internal/data/hrv.go b/internal/data/hrv.go index aecff50..1c391e4 100644 --- a/internal/data/hrv.go +++ b/internal/data/hrv.go @@ -7,8 +7,8 @@ import ( "sort" "time" - "garmin-connect/internal/api/client" - "garmin-connect/internal/utils" + "go-garth/internal/api/client" + "go-garth/internal/utils" ) // HRVSummary represents Heart Rate Variability summary data diff --git a/internal/data/sleep.go b/internal/data/sleep.go index 3d3a144..c9cc0ae 100644 --- a/internal/data/sleep.go +++ b/internal/data/sleep.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "garmin-connect/internal/api/client" + "go-garth/internal/api/client" ) // SleepScores represents sleep scoring data diff --git a/internal/data/weight.go b/internal/data/weight.go index fa10d8c..4b8aba2 100644 --- a/internal/data/weight.go +++ b/internal/data/weight.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "garmin-connect/internal/api/client" + "go-garth/internal/api/client" ) // WeightData represents weight measurement data diff --git a/internal/stats/base.go b/internal/stats/base.go index 22fa7f9..ff1f856 100644 --- a/internal/stats/base.go +++ b/internal/stats/base.go @@ -6,8 +6,8 @@ import ( "strings" "time" - "garmin-connect/internal/api/client" - "garmin-connect/internal/utils" + "go-garth/internal/api/client" + "go-garth/internal/utils" ) type Stats interface { diff --git a/internal/testutils/mock_client.go b/internal/testutils/mock_client.go index 2698dc8..e64e348 100644 --- a/internal/testutils/mock_client.go +++ b/internal/testutils/mock_client.go @@ -5,7 +5,7 @@ import ( "io" "net/url" - "garmin-connect/internal/api/client" + "go-garth/internal/api/client" ) // MockClient simulates API client for tests diff --git a/internal/types/garmin.go b/internal/types/garmin.go index 4e7513a..d07f634 100644 --- a/internal/types/garmin.go +++ b/internal/types/garmin.go @@ -65,8 +65,8 @@ type Activity struct { ActivityID int64 `json:"activityId"` ActivityName string `json:"activityName"` Description string `json:"description"` - StartTimeLocal string `json:"startTimeLocal"` - StartTimeGMT string `json:"startTimeGMT"` + StartTimeLocal GarminTime `json:"startTimeLocal"` + StartTimeGMT GarminTime `json:"startTimeGMT"` ActivityType ActivityType `json:"activityType"` EventType EventType `json:"eventType"` Distance float64 `json:"distance"` @@ -89,3 +89,95 @@ type UserProfile struct { LevelUpdateDate GarminTime `json:"levelUpdateDate"` // Add other fields as needed from API response } + +// VO2MaxData represents VO2 max data +type VO2MaxData struct { + Date time.Time `json:"calendarDate"` + VO2MaxRunning float64 `json:"vo2MaxRunning"` + VO2MaxCycling float64 `json:"vo2MaxCycling"` + // Add more fields as needed +} + +// HeartRateZones represents heart rate zone data +type HeartRateZones struct { + RestingHR int `json:"resting_hr"` + MaxHR int `json:"max_hr"` + LactateThreshold int `json:"lactate_threshold"` + Zones []HRZone `json:"zones"` + UpdatedAt time.Time `json:"updated_at"` +} + +// HRZone represents a single heart rate zone +type HRZone struct { + Zone int `json:"zone"` + MinBPM int `json:"min_bpm"` + MaxBPM int `json:"max_bpm"` + Name string `json:"name"` +} + +// WellnessData represents additional wellness metrics +type WellnessData struct { + Date time.Time `json:"calendarDate"` + RestingHR *int `json:"resting_hr"` + Weight *float64 `json:"weight"` + BodyFat *float64 `json:"body_fat"` + BMI *float64 `json:"bmi"` + BodyWater *float64 `json:"body_water"` + BoneMass *float64 `json:"bone_mass"` + MuscleMass *float64 `json:"muscle_mass"` + // Add more fields as needed +} + +// SleepData represents sleep summary data +type SleepData struct { + Date time.Time `json:"calendarDate"` + SleepScore int `json:"sleepScore"` + TotalSleepSeconds int `json:"totalSleepSeconds"` + DeepSleepSeconds int `json:"deepSleepSeconds"` + LightSleepSeconds int `json:"lightSleepSeconds"` + RemSleepSeconds int `json:"remSleepSeconds"` + AwakeSleepSeconds int `json:"awakeSleepSeconds"` + // Add more fields as needed +} + +// HrvData represents Heart Rate Variability data +type HrvData struct { + Date time.Time `json:"calendarDate"` + HrvValue float64 `json:"hrvValue"` + // Add more fields as needed +} + +// StressData represents stress level data +type StressData struct { + Date time.Time `json:"calendarDate"` + StressLevel int `json:"stressLevel"` + RestStressLevel int `json:"restStressLevel"` + // Add more fields as needed +} + +// BodyBatteryData represents Body Battery data +type BodyBatteryData struct { + Date time.Time `json:"calendarDate"` + BatteryLevel int `json:"batteryLevel"` + Charge int `json:"charge"` + Drain int `json:"drain"` + // Add more fields as needed +} + +// StepsData represents steps statistics +type StepsData struct { + Date time.Time `json:"calendarDate"` + Steps int `json:"steps"` +} + +// DistanceData represents distance statistics +type DistanceData struct { + Date time.Time `json:"calendarDate"` + Distance float64 `json:"distance"` // in meters +} + +// CaloriesData represents calories statistics +type CaloriesData struct { + Date time.Time `json:"calendarDate"` + Calories int `json:"activeCalories"` +} \ No newline at end of file diff --git a/internal/users/settings.go b/internal/users/settings.go index 5335443..ba58822 100644 --- a/internal/users/settings.go +++ b/internal/users/settings.go @@ -3,7 +3,7 @@ package users import ( "time" - "garmin-connect/internal/api/client" + "go-garth/internal/api/client" ) type PowerFormat struct { diff --git a/internal/utils/timeutils.go b/internal/utils/timeutils.go index 11838ed..8cbad83 100644 --- a/internal/utils/timeutils.go +++ b/internal/utils/timeutils.go @@ -2,6 +2,7 @@ package utils import ( "time" + "strconv" ) var ( @@ -36,3 +37,29 @@ func ToLocalTime(utcTime time.Time) time.Time { func ToUTCTime(localTime time.Time) time.Time { return localTime.UTC() } + +// parseAggregationKey is a 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{} +} \ No newline at end of file diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 8ec6527..7deb229 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -6,7 +6,7 @@ import ( "crypto/sha1" "encoding/base64" "encoding/json" - "garmin-connect/internal/types" + "go-garth/internal/types" "net/http" "net/url" "regexp" diff --git a/main.go b/main.go index d8f2be2..c62e98a 100644 --- a/main.go +++ b/main.go @@ -5,9 +5,9 @@ import ( "log" "time" - "garmin-connect/internal/api/client" - "garmin-connect/internal/auth/credentials" - types "garmin-connect/pkg/garmin" + "go-garth/internal/api/client" + "go-garth/internal/auth/credentials" + types "go-garth/pkg/garmin" ) func main() { diff --git a/phase1.md b/phase1.md index cb63ae9..a11d3b0 100644 --- a/phase1.md +++ b/phase1.md @@ -370,17 +370,17 @@ type VO2MaxReading struct { ``` **Tasks:** -- [ ] Research VO2 max API endpoints -- [ ] Implement data fetching -- [ ] Add historical data support -- [ ] Create CLI command -- [ ] Add data validation -- [ ] Format output appropriately +- [x] Research VO2 max API endpoints +- [x] Implement data fetching +- [x] Add historical data support +- [x] Create CLI command +- [x] Add data validation +- [x] Format output appropriately **Deliverables:** -- [ ] `garth health vo2max` command working -- [ ] Historical data support -- [ ] Both running and cycling metrics +- [x] `garth health vo2max` command working +- [x] Historical data support +- [x] Both running and cycling metrics #### 1D.2: Heart Rate Zones **Duration: 1 day** @@ -403,15 +403,15 @@ type HRZone struct { ``` **Tasks:** -- [ ] Implement HR zones API calls -- [ ] Add zone calculation logic -- [ ] Create CLI command -- [ ] Add zone analysis features -- [ ] Implement zone updates (if possible) +- [x] Implement HR zones API calls +- [x] Add zone calculation logic +- [x] Create CLI command +- [x] Add zone analysis features +- [x] Implement zone updates (if possible) **Deliverables:** -- [ ] `garth health hr-zones` command -- [ ] Zone calculation and display +- [x] `garth health hr-zones` command +- [x] Zone calculation and display - [ ] Integration with other health metrics #### 1D.3: Additional Health Metrics diff --git a/pkg/garmin/benchmark_test.go b/pkg/garmin/benchmark_test.go index a86be7a..e4abc7b 100644 --- a/pkg/garmin/benchmark_test.go +++ b/pkg/garmin/benchmark_test.go @@ -2,9 +2,9 @@ package garmin_test import ( "encoding/json" - "garmin-connect/internal/api/client" - "garmin-connect/internal/data" - "garmin-connect/internal/testutils" + "go-garth/internal/api/client" + "go-garth/internal/data" + "go-garth/internal/testutils" "testing" "time" ) diff --git a/pkg/garmin/client.go b/pkg/garmin/client.go index 3001043..b2c85a3 100644 --- a/pkg/garmin/client.go +++ b/pkg/garmin/client.go @@ -2,13 +2,13 @@ package garmin import ( "fmt" + "os" + "path/filepath" "time" - internalClient "garmin-connect/internal/api/client" - "garmin-connect/internal/types" - "garmin-connect/pkg/garmin/activities" - "garmin-connect/pkg/garmin/health" - "garmin-connect/pkg/garmin/stats" + internalClient "go-garth/internal/api/client" + "go-garth/internal/errors" + "go-garth/internal/types" ) // Client is the main Garmin Connect client type @@ -22,7 +22,7 @@ func NewClient(domain string) (*Client, error) { if err != nil { return nil, err } - return &Client{Client: c}, + return &Client{Client: c}, nil } // Login authenticates to Garmin Connect @@ -45,8 +45,8 @@ func (c *Client) RefreshSession() error { return c.Client.RefreshSession() } -// GetActivities retrieves recent activities -func (c *Client) GetActivities(opts activities.ActivityOptions) ([]Activity, error) { +// ListActivities retrieves recent activities +func (c *Client) ListActivities(opts ActivityOptions) ([]Activity, error) { // TODO: Map ActivityOptions to internalClient.Client.GetActivities parameters // For now, just call the internal client's GetActivities with a dummy limit internalActivities, err := c.Client.GetActivities(opts.Limit) @@ -60,7 +60,7 @@ func (c *Client) GetActivities(opts activities.ActivityOptions) ([]Activity, err ActivityID: act.ActivityID, ActivityName: act.ActivityName, ActivityType: act.ActivityType, - Starttime: act.Starttime, + StartTimeLocal: act.StartTimeLocal, Distance: act.Distance, Duration: act.Duration, }) @@ -69,13 +69,13 @@ func (c *Client) GetActivities(opts activities.ActivityOptions) ([]Activity, err } // GetActivity retrieves details for a specific activity ID -func (c *Client) GetActivity(activityID int) (*activities.ActivityDetail, error) { +func (c *Client) GetActivity(activityID int) (*ActivityDetail, error) { // TODO: Implement internalClient.Client.GetActivity return nil, fmt.Errorf("not implemented") } // DownloadActivity downloads activity data -func (c *Client) DownloadActivity(activityID int, opts activities.DownloadOptions) error { +func (c *Client) DownloadActivity(activityID int, opts DownloadOptions) error { // TODO: Determine file extension based on format fileExtension := opts.Format if fileExtension == "csv" { @@ -133,45 +133,53 @@ func (c *Client) SearchActivities(query string) ([]Activity, error) { } // GetSleepData retrieves sleep data for a specified date range -func (c *Client) GetSleepData(startDate, endDate time.Time) ([]health.SleepData, error) { - // TODO: Implement internalClient.Client.GetSleepData - return nil, fmt.Errorf("not implemented") +func (c *Client) GetSleepData(startDate, endDate time.Time) ([]types.SleepData, error) { + return c.Client.GetSleepData(startDate, endDate) } // GetHrvData retrieves HRV data for a specified number of days -func (c *Client) GetHrvData(days int) ([]health.HrvData, error) { - // TODO: Implement internalClient.Client.GetHrvData - return nil, fmt.Errorf("not implemented") +func (c *Client) GetHrvData(days int) ([]types.HrvData, error) { + return c.Client.GetHrvData(days) } // GetStressData retrieves stress data -func (c *Client) GetStressData(startDate, endDate time.Time) ([]health.StressData, error) { - // TODO: Implement internalClient.Client.GetStressData - return nil, fmt.Errorf("not implemented") +func (c *Client) GetStressData(startDate, endDate time.Time) ([]types.StressData, error) { + return c.Client.GetStressData(startDate, endDate) } // GetBodyBatteryData retrieves Body Battery data -func (c *Client) GetBodyBatteryData(startDate, endDate time.Time) ([]health.BodyBatteryData, error) { - // TODO: Implement internalClient.Client.GetBodyBatteryData - return nil, fmt.Errorf("not implemented") +func (c *Client) GetBodyBatteryData(startDate, endDate time.Time) ([]types.BodyBatteryData, error) { + return c.Client.GetBodyBatteryData(startDate, endDate) } // GetStepsData retrieves steps data for a specified date range -func (c *Client) GetStepsData(startDate, endDate time.Time) ([]stats.StepsData, error) { - // TODO: Implement internalClient.Client.GetStepsData - return nil, fmt.Errorf("not implemented") +func (c *Client) GetStepsData(startDate, endDate time.Time) ([]types.StepsData, error) { + return c.Client.GetStepsData(startDate, endDate) } // GetDistanceData retrieves distance data for a specified date range -func (c *Client) GetDistanceData(startDate, endDate time.Time) ([]stats.DistanceData, error) { - // TODO: Implement internalClient.Client.GetDistanceData - return nil, fmt.Errorf("not implemented") +func (c *Client) GetDistanceData(startDate, endDate time.Time) ([]types.DistanceData, error) { + return c.Client.GetDistanceData(startDate, endDate) } // GetCaloriesData retrieves calories data for a specified date range -func (c *Client) GetCaloriesData(startDate, endDate time.Time) ([]stats.CaloriesData, error) { - // TODO: Implement internalClient.Client.GetCaloriesData - return nil, fmt.Errorf("not implemented") +func (c *Client) GetCaloriesData(startDate, endDate time.Time) ([]types.CaloriesData, error) { + return c.Client.GetCaloriesData(startDate, endDate) +} + +// GetVO2MaxData retrieves VO2 max data for a specified date range +func (c *Client) GetVO2MaxData(startDate, endDate time.Time) ([]types.VO2MaxData, error) { + return c.Client.GetVO2MaxData(startDate, endDate) +} + +// GetHeartRateZones retrieves heart rate zone data +func (c *Client) GetHeartRateZones() (*types.HeartRateZones, error) { + return c.Client.GetHeartRateZones() +} + +// GetWellnessData retrieves comprehensive wellness data for a specified date range +func (c *Client) GetWellnessData(startDate, endDate time.Time) ([]types.WellnessData, error) { + return c.Client.GetWellnessData(startDate, endDate) } // OAuth1Token returns the OAuth1 token diff --git a/pkg/garmin/health.go b/pkg/garmin/health.go index a9e137b..d6fe1be 100644 --- a/pkg/garmin/health.go +++ b/pkg/garmin/health.go @@ -38,4 +38,42 @@ type BodyBatteryData struct { Charge int `json:"charge"` Drain int `json:"drain"` // Add more fields as needed +} + +// VO2MaxData represents VO2 max data +type VO2MaxData struct { + Date time.Time `json:"calendarDate"` + VO2MaxRunning float64 `json:"vo2MaxRunning"` + VO2MaxCycling float64 `json:"vo2MaxCycling"` + // Add more fields as needed +} + +// HeartRateZones represents heart rate zone data +type HeartRateZones struct { + RestingHR int `json:"resting_hr"` + MaxHR int `json:"max_hr"` + LactateThreshold int `json:"lactate_threshold"` + Zones []HRZone `json:"zones"` + UpdatedAt time.Time `json:"updated_at"` +} + +// HRZone represents a single heart rate zone +type HRZone struct { + Zone int `json:"zone"` + MinBPM int `json:"min_bpm"` + MaxBPM int `json:"max_bpm"` + Name string `json:"name"` +} + +// WellnessData represents additional wellness metrics +type WellnessData struct { + Date time.Time `json:"calendarDate"` + RestingHR *int `json:"resting_hr"` + Weight *float64 `json:"weight"` + BodyFat *float64 `json:"body_fat"` + BMI *float64 `json:"bmi"` + BodyWater *float64 `json:"body_water"` + BoneMass *float64 `json:"bone_mass"` + MuscleMass *float64 `json:"muscle_mass"` + // Add more fields as needed } \ No newline at end of file diff --git a/pkg/garmin/integration_test.go b/pkg/garmin/integration_test.go index ff78c32..5462ee6 100644 --- a/pkg/garmin/integration_test.go +++ b/pkg/garmin/integration_test.go @@ -4,9 +4,9 @@ import ( "testing" "time" - "garmin-connect/internal/api/client" - "garmin-connect/internal/data" - "garmin-connect/internal/stats" + "go-garth/internal/api/client" + "go-garth/internal/data" + "go-garth/internal/stats" ) func TestBodyBatteryIntegration(t *testing.T) { diff --git a/pkg/garmin/stats.go b/pkg/garmin/stats.go index d3f9fc3..9683f71 100644 --- a/pkg/garmin/stats.go +++ b/pkg/garmin/stats.go @@ -3,7 +3,7 @@ package garmin import ( "time" - "garmin-connect/internal/stats" + "go-garth/internal/stats" ) // Stats is an interface for stats data types. diff --git a/pkg/garmin/types.go b/pkg/garmin/types.go index dabfd9a..b1bdd06 100644 --- a/pkg/garmin/types.go +++ b/pkg/garmin/types.go @@ -1,6 +1,6 @@ package garmin -import "garmin-connect/internal/types" +import "go-garth/internal/types" // GarminTime represents Garmin's timestamp format with custom JSON parsing type GarminTime = types.GarminTime