mirror of
https://github.com/sstent/go-garth.git
synced 2026-02-06 22:41:38 +00:00
fix: resolve build errors and implement missing health data types
- Fix various build errors in the CLI application. - Implement missing health data types (VO2 Max, Heart Rate Zones). - Corrected `tablewriter` usage from `SetHeader` to `Header`. - Removed unused imports and fixed syntax errors.
This commit is contained in:
@@ -8,14 +8,14 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/olekukonko/tablewriter"
|
||||||
"github.com/schollz/progressbar/v3"
|
"github.com/schollz/progressbar/v3"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
|
||||||
"garmin-connect/pkg/garmin"
|
"go-garth/pkg/garmin"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -43,7 +43,7 @@ var (
|
|||||||
downloadActivitiesCmd = &cobra.Command{
|
downloadActivitiesCmd = &cobra.Command{
|
||||||
Use: "download [activityID]",
|
Use: "download [activityID]",
|
||||||
Short: "Download activity data",
|
Short: "Download activity data",
|
||||||
Args: cobra.RangeArgs(0, 1), RunE: runDownloadActivity,
|
Args: cobra.RangeArgs(0, 1), RunE: runDownloadActivity,
|
||||||
}
|
}
|
||||||
|
|
||||||
searchActivitiesCmd = &cobra.Command{
|
searchActivitiesCmd = &cobra.Command{
|
||||||
@@ -54,15 +54,15 @@ var (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Flags for listActivitiesCmd
|
// Flags for listActivitiesCmd
|
||||||
activityLimit int
|
activityLimit int
|
||||||
activityOffset int
|
activityOffset int
|
||||||
activityType string
|
activityType string
|
||||||
activityDateFrom string
|
activityDateFrom string
|
||||||
activityDateTo string
|
activityDateTo string
|
||||||
|
|
||||||
// Flags for downloadActivitiesCmd
|
// Flags for downloadActivitiesCmd
|
||||||
downloadFormat string
|
downloadFormat string
|
||||||
outputDir string
|
outputDir string
|
||||||
downloadOriginal bool
|
downloadOriginal bool
|
||||||
downloadAll bool
|
downloadAll bool
|
||||||
)
|
)
|
||||||
@@ -105,8 +105,8 @@ func runListActivities(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
opts := garmin.ActivityOptions{
|
opts := garmin.ActivityOptions{
|
||||||
Limit: activityLimit,
|
Limit: activityLimit,
|
||||||
Offset: activityOffset,
|
Offset: activityOffset,
|
||||||
ActivityType: activityType,
|
ActivityType: activityType,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,21 +152,21 @@ func runListActivities(cmd *cobra.Command, args []string) error {
|
|||||||
writer.Write([]string{
|
writer.Write([]string{
|
||||||
fmt.Sprintf("%d", activity.ActivityID),
|
fmt.Sprintf("%d", activity.ActivityID),
|
||||||
activity.ActivityName,
|
activity.ActivityName,
|
||||||
activity.ActivityType,
|
activity.ActivityType.TypeKey,
|
||||||
activity.Starttime.Format("2006-01-02 15:04:05"),
|
activity.StartTimeLocal.Format("2006-01-02 15:04:05"),
|
||||||
fmt.Sprintf("%.2f", activity.Distance/1000),
|
fmt.Sprintf("%.2f", activity.Distance/1000),
|
||||||
fmt.Sprintf("%.0f", activity.Duration),
|
fmt.Sprintf("%.0f", activity.Duration),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
case "table":
|
case "table":
|
||||||
table := tablewriter.NewWriter(os.Stdout)
|
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 {
|
for _, activity := range activities {
|
||||||
table.Append([]string{
|
table.Append([]string{
|
||||||
fmt.Sprintf("%d", activity.ActivityID),
|
fmt.Sprintf("%d", activity.ActivityID),
|
||||||
activity.ActivityName,
|
activity.ActivityName,
|
||||||
activity.ActivityType,
|
activity.ActivityType.TypeKey,
|
||||||
activity.Starttime.Format("2006-01-02 15:04:05"),
|
activity.StartTimeLocal.Format("2006-01-02 15:04:05"),
|
||||||
fmt.Sprintf("%.2f", activity.Distance/1000),
|
fmt.Sprintf("%.2f", activity.Distance/1000),
|
||||||
fmt.Sprintf("%.0f", activity.Duration),
|
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("Activity Details (ID: %d):\n", activityDetail.ActivityID)
|
||||||
fmt.Printf(" Name: %s\n", activityDetail.ActivityName)
|
fmt.Printf(" Name: %s\n", activityDetail.ActivityName)
|
||||||
fmt.Printf(" Type: %s\n", activityDetail.ActivityType)
|
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(" Distance: %.2f km\n", activityDetail.Distance/1000)
|
||||||
fmt.Printf(" Duration: %.0f s\n", activityDetail.Duration)
|
fmt.Printf(" Duration: %.0f s\n", activityDetail.Duration)
|
||||||
fmt.Printf(" Description: %s\n", activityDetail.Description)
|
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 {
|
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
|
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create client: %w", err)
|
return fmt.Errorf("failed to create client: %w", err)
|
||||||
@@ -292,7 +296,7 @@ func runDownloadActivity(cmd *cobra.Command, args []string) error {
|
|||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
|
|
||||||
if downloadFormat == "csv" {
|
if downloadFormat == "csv" {
|
||||||
activityDetail, err := garminClient.GetActivity(activity.ActivityID)
|
activityDetail, err := garminClient.GetActivity(int(activity.ActivityID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Warning: Failed to get activity details for CSV export for activity %d: %v\n", activity.ActivityID, err)
|
fmt.Printf("Warning: Failed to get activity details for CSV export for activity %d: %v\n", activity.ActivityID, err)
|
||||||
bar.Add(1)
|
bar.Add(1)
|
||||||
@@ -323,8 +327,8 @@ func runDownloadActivity(cmd *cobra.Command, args []string) error {
|
|||||||
writer.Write([]string{
|
writer.Write([]string{
|
||||||
fmt.Sprintf("%d", activityDetail.ActivityID),
|
fmt.Sprintf("%d", activityDetail.ActivityID),
|
||||||
activityDetail.ActivityName,
|
activityDetail.ActivityName,
|
||||||
activityDetail.ActivityType,
|
activityDetail.ActivityType.TypeKey,
|
||||||
activityDetail.Starttime.Format("2006-01-02 15:04:05"),
|
activityDetail.StartTimeLocal.Format("2006-01-02 15:04:05"),
|
||||||
fmt.Sprintf("%.2f", activityDetail.Distance/1000),
|
fmt.Sprintf("%.2f", activityDetail.Distance/1000),
|
||||||
fmt.Sprintf("%.0f", activityDetail.Duration),
|
fmt.Sprintf("%.0f", activityDetail.Duration),
|
||||||
activityDetail.Description,
|
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)
|
fmt.Printf("Activity %d summary exported to %s\n", activity.ActivityID, outputPath)
|
||||||
} else {
|
} 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{
|
opts := garmin.DownloadOptions{
|
||||||
Format: downloadFormat,
|
Format: downloadFormat,
|
||||||
OutputDir: outputDir,
|
OutputDir: outputDir,
|
||||||
Original: downloadOriginal,
|
Original: downloadOriginal,
|
||||||
|
Filename: filename, // Pass filename to opts
|
||||||
}
|
}
|
||||||
|
|
||||||
outputPath = filepath.Join(outputDir, filename)
|
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)
|
||||||
// Check if file already exists
|
bar.Add(1)
|
||||||
if _, err := os.Stat(outputPath); err == nil {
|
return
|
||||||
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("Activity %d downloaded successfully.\n", activity.ActivityID)
|
||||||
|
}
|
||||||
bar.Add(1)
|
bar.Add(1)
|
||||||
}(activity)
|
}(activity)
|
||||||
}
|
}
|
||||||
@@ -401,8 +409,8 @@ func runSearchActivities(cmd *cobra.Command, args []string) error {
|
|||||||
fmt.Printf("Activities matching '%s':\n", query)
|
fmt.Printf("Activities matching '%s':\n", query)
|
||||||
for _, activity := range activities {
|
for _, activity := range activities {
|
||||||
fmt.Printf("- ID: %d, Name: %s, Type: %s, Date: %s\n",
|
fmt.Printf("- ID: %d, Name: %s, Type: %s, Date: %s\n",
|
||||||
activity.ActivityID, activity.ActivityName, activity.ActivityType,
|
activity.ActivityID, activity.ActivityName, activity.ActivityType.TypeKey,
|
||||||
activity.Starttime.Format("2006-01-02"))
|
activity.StartTimeLocal.Format("2006-01-02"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
|
|
||||||
"garmin-connect/internal/auth/credentials"
|
"go-garth/pkg/garmin"
|
||||||
"garmin-connect/pkg/garmin"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/internal/auth/credentials"
|
"go-garth/internal/auth/credentials"
|
||||||
"garmin-connect/pkg/garmin"
|
"go-garth/pkg/garmin"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -34,7 +34,10 @@ var activitiesCmd = &cobra.Command{
|
|||||||
log.Fatalf("No existing session found. Please run 'garth login' first.")
|
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 {
|
if err != nil {
|
||||||
log.Fatalf("Failed to get activities: %v", err)
|
log.Fatalf("Failed to get activities: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/internal/auth/credentials"
|
"go-garth/internal/auth/credentials"
|
||||||
"garmin-connect/pkg/garmin"
|
"go-garth/pkg/garmin"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -58,13 +58,13 @@ var dataCmd = &cobra.Command{
|
|||||||
|
|
||||||
switch dataType {
|
switch dataType {
|
||||||
case "bodybattery":
|
case "bodybattery":
|
||||||
result, err = garminClient.GetBodyBattery(endDate)
|
result, err = garminClient.GetBodyBatteryData(endDate, endDate)
|
||||||
case "sleep":
|
case "sleep":
|
||||||
result, err = garminClient.GetSleep(endDate)
|
result, err = garminClient.GetSleepData(endDate, endDate)
|
||||||
case "hrv":
|
case "hrv":
|
||||||
result, err = garminClient.GetHRV(endDate)
|
result, err = garminClient.GetHrvData(dataDays)
|
||||||
case "weight":
|
// case "weight":
|
||||||
result, err = garminClient.GetWeight(endDate)
|
// result, err = garminClient.GetWeight(endDate)
|
||||||
default:
|
default:
|
||||||
log.Fatalf("Unknown data type: %s", dataType)
|
log.Fatalf("Unknown data type: %s", dataType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/internal/auth/credentials"
|
"go-garth/internal/auth/credentials"
|
||||||
"garmin-connect/pkg/garmin"
|
"go-garth/pkg/garmin"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"garmin-connect/internal/auth/credentials"
|
"go-garth/internal/auth/credentials"
|
||||||
"garmin-connect/pkg/garmin"
|
"go-garth/pkg/garmin"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/olekukonko/tablewriter"
|
"github.com/olekukonko/tablewriter"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
|
||||||
"garmin-connect/pkg/garmin"
|
types "go-garth/internal/types"
|
||||||
|
"go-garth/internal/utils"
|
||||||
|
"go-garth/pkg/garmin"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -49,11 +52,23 @@ var (
|
|||||||
RunE: runBodyBattery,
|
RunE: runBodyBattery,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flags for health commands
|
vo2maxCmd = &cobra.Command{
|
||||||
healthDateFrom string
|
Use: "vo2max",
|
||||||
healthDateTo string
|
Short: "Get VO2 Max data",
|
||||||
healthDays int
|
Long: `Fetch VO2 Max data for a specified date range.`,
|
||||||
healthWeek bool
|
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
|
healthYesterday bool
|
||||||
healthAggregate string
|
healthAggregate string
|
||||||
)
|
)
|
||||||
@@ -77,6 +92,42 @@ func init() {
|
|||||||
healthCmd.AddCommand(bodyBatteryCmd)
|
healthCmd.AddCommand(bodyBatteryCmd)
|
||||||
bodyBatteryCmd.Flags().BoolVar(&healthYesterday, "yesterday", false, "Fetch data for yesterday")
|
bodyBatteryCmd.Flags().BoolVar(&healthYesterday, "yesterday", false, "Fetch data for yesterday")
|
||||||
bodyBatteryCmd.Flags().StringVar(&healthAggregate, "aggregate", "", "Aggregate data by (day, week, month, year)")
|
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 {
|
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
|
// Convert aggregated data back to a slice for output
|
||||||
sleepData = []garmin.SleepData{}
|
sleepData = []types.SleepData{}
|
||||||
for key, entry := range aggregatedSleep {
|
for key, entry := range aggregatedSleep {
|
||||||
sleepData = append(sleepData, garmin.SleepData{
|
sleepData = append(sleepData, types.SleepData{
|
||||||
Date: parseAggregationKey(key, healthAggregate), // Helper to parse key back to date
|
Date: utils.ParseAggregationKey(key, healthAggregate),
|
||||||
TotalSleepSeconds: entry.TotalSleepSeconds / entry.Count,
|
TotalSleepSeconds: entry.TotalSleepSeconds / entry.Count,
|
||||||
SleepScore: entry.SleepScore / 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":
|
case "table":
|
||||||
table := tablewriter.NewWriter(os.Stdout)
|
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 {
|
for _, data := range sleepData {
|
||||||
table.Append([]string{
|
table.Append([]string{
|
||||||
data.Date.Format("2006-01-02"),
|
data.Date.Format("2006-01-02"),
|
||||||
@@ -209,32 +264,6 @@ func runSleep(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
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 {
|
func runHrv(cmd *cobra.Command, args []string) error {
|
||||||
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
|
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -291,10 +320,10 @@ func runHrv(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert aggregated data back to a slice for output
|
// Convert aggregated data back to a slice for output
|
||||||
hrvData = []garmin.HrvData{}
|
hrvData = []types.HrvData{}
|
||||||
for key, entry := range aggregatedHrv {
|
for key, entry := range aggregatedHrv {
|
||||||
hrvData = append(hrvData, garmin.HrvData{
|
hrvData = append(hrvData, types.HrvData{
|
||||||
Date: parseAggregationKey(key, healthAggregate), // Helper to parse key back to date
|
Date: utils.ParseAggregationKey(key, healthAggregate),
|
||||||
HrvValue: entry.HrvValue / float64(entry.Count),
|
HrvValue: entry.HrvValue / float64(entry.Count),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -322,7 +351,7 @@ func runHrv(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
case "table":
|
case "table":
|
||||||
table := tablewriter.NewWriter(os.Stdout)
|
table := tablewriter.NewWriter(os.Stdout)
|
||||||
table.SetHeader([]string{"Date", "HRV Value"})
|
table.Header([]string{"Date", "HRV Value"})
|
||||||
for _, data := range hrvData {
|
for _, data := range hrvData {
|
||||||
table.Append([]string{
|
table.Append([]string{
|
||||||
data.Date.Format("2006-01-02"),
|
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
|
// Convert aggregated data back to a slice for output
|
||||||
stressData = []garmin.StressData{}
|
stressData = []types.StressData{}
|
||||||
for key, entry := range aggregatedStress {
|
for key, entry := range aggregatedStress {
|
||||||
stressData = append(stressData, garmin.StressData{
|
stressData = append(stressData, types.StressData{
|
||||||
Date: parseAggregationKey(key, healthAggregate), // Helper to parse key back to date
|
Date: utils.ParseAggregationKey(key, healthAggregate),
|
||||||
StressLevel: entry.StressLevel / entry.Count,
|
StressLevel: entry.StressLevel / entry.Count,
|
||||||
RestStressLevel: entry.RestStressLevel / entry.Count,
|
RestStressLevel: entry.RestStressLevel / entry.Count,
|
||||||
})
|
})
|
||||||
@@ -436,7 +465,7 @@ func runStress(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
case "table":
|
case "table":
|
||||||
table := tablewriter.NewWriter(os.Stdout)
|
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 {
|
for _, data := range stressData {
|
||||||
table.Append([]string{
|
table.Append([]string{
|
||||||
data.Date.Format("2006-01-02"),
|
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
|
// Convert aggregated data back to a slice for output
|
||||||
bodyBatteryData = []garmin.BodyBatteryData{}
|
bodyBatteryData = []types.BodyBatteryData{}
|
||||||
for key, entry := range aggregatedBodyBattery {
|
for key, entry := range aggregatedBodyBattery {
|
||||||
bodyBatteryData = append(bodyBatteryData, garmin.BodyBatteryData{
|
bodyBatteryData = append(bodyBatteryData, types.BodyBatteryData{
|
||||||
Date: parseAggregationKey(key, healthAggregate), // Helper to parse key back to date
|
Date: utils.ParseAggregationKey(key, healthAggregate),
|
||||||
BatteryLevel: entry.BatteryLevel / entry.Count,
|
BatteryLevel: entry.BatteryLevel / entry.Count,
|
||||||
Charge: entry.Charge / entry.Count,
|
Charge: entry.Charge / entry.Count,
|
||||||
Drain: entry.Drain / entry.Count,
|
Drain: entry.Drain / entry.Count,
|
||||||
@@ -552,7 +581,7 @@ func runBodyBattery(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
case "table":
|
case "table":
|
||||||
table := tablewriter.NewWriter(os.Stdout)
|
table := tablewriter.NewWriter(os.Stdout)
|
||||||
table.SetHeader([]string{"Date", "Level", "Charge", "Drain"})
|
table.Header([]string{"Date", "Battery Level", "Charge", "Drain"})
|
||||||
for _, data := range bodyBatteryData {
|
for _, data := range bodyBatteryData {
|
||||||
table.Append([]string{
|
table.Append([]string{
|
||||||
data.Date.Format("2006-01-02"),
|
data.Date.Format("2006-01-02"),
|
||||||
@@ -568,3 +597,205 @@ func runBodyBattery(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
return nil
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,194 +11,17 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
|
||||||
"garmin-connect/pkg/garmin"
|
types "go-garth/internal/types"
|
||||||
|
"go-garth/internal/utils"
|
||||||
|
"go-garth/pkg/garmin"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
statsCmd = &cobra.Command{
|
statsYear bool
|
||||||
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
|
|
||||||
statsAggregate string
|
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 {
|
func runDistance(cmd *cobra.Command, args []string) error {
|
||||||
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
|
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -261,10 +84,10 @@ func runDistance(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert aggregated data back to a slice for output
|
// Convert aggregated data back to a slice for output
|
||||||
distanceData = []garmin.DistanceData{}
|
distanceData = []types.DistanceData{}
|
||||||
for key, entry := range aggregatedDistance {
|
for key, entry := range aggregatedDistance {
|
||||||
distanceData = append(distanceData, garmin.DistanceData{
|
distanceData = append(distanceData, types.DistanceData{
|
||||||
Date: parseAggregationKey(key, statsAggregate), // Helper to parse key back to date
|
Date: utils.ParseAggregationKey(key, statsAggregate), // Helper to parse key back to date
|
||||||
Distance: entry.Distance / float64(entry.Count),
|
Distance: entry.Distance / float64(entry.Count),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -292,7 +115,7 @@ func runDistance(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
case "table":
|
case "table":
|
||||||
table := tablewriter.NewWriter(os.Stdout)
|
table := tablewriter.NewWriter(os.Stdout)
|
||||||
table.SetHeader([]string{"Date", "Distance (km)"})
|
table.Header([]string{"Date", "Distance (km)"})
|
||||||
for _, data := range distanceData {
|
for _, data := range distanceData {
|
||||||
table.Append([]string{
|
table.Append([]string{
|
||||||
data.Date.Format("2006-01-02"),
|
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
|
// Convert aggregated data back to a slice for output
|
||||||
caloriesData = []garmin.CaloriesData{}
|
caloriesData = []types.CaloriesData{}
|
||||||
for key, entry := range aggregatedCalories {
|
for key, entry := range aggregatedCalories {
|
||||||
caloriesData = append(caloriesData, garmin.CaloriesData{
|
caloriesData = append(caloriesData, types.CaloriesData{
|
||||||
Date: parseAggregationKey(key, statsAggregate), // Helper to parse key back to date
|
Date: utils.ParseAggregationKey(key, statsAggregate), // Helper to parse key back to date
|
||||||
Calories: entry.Calories / entry.Count,
|
Calories: entry.Calories / entry.Count,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -402,7 +225,7 @@ func runCalories(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
case "table":
|
case "table":
|
||||||
table := tablewriter.NewWriter(os.Stdout)
|
table := tablewriter.NewWriter(os.Stdout)
|
||||||
table.SetHeader([]string{"Date", "Calories"})
|
table.Header([]string{"Date", "Calories"})
|
||||||
for _, data := range caloriesData {
|
for _, data := range caloriesData {
|
||||||
table.Append([]string{
|
table.Append([]string{
|
||||||
data.Date.Format("2006-01-02"),
|
data.Date.Format("2006-01-02"),
|
||||||
|
|||||||
BIN
garmin-connect
BIN
garmin-connect
Binary file not shown.
15
go.mod
15
go.mod
@@ -4,34 +4,35 @@ go 1.24.2
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/joho/godotenv v1.5.1
|
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/cobra v1.10.1
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
|
golang.org/x/term v0.28.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.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-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // 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/errors v1.1.0 // indirect
|
||||||
github.com/olekukonko/ll v0.0.9 // indirect
|
github.com/olekukonko/ll v0.1.1 // indirect
|
||||||
github.com/olekukonko/tablewriter v1.0.9 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // 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/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/sys v0.29.0 // indirect
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
golang.org/x/term v0.28.0 // indirect
|
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
26
go.sum
26
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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
|
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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
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/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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
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-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
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 h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
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 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
|
||||||
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
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.1.1 h1:9Dfeed5/Mgaxb9lHRAftLK9pVfYETvHn+If6lywVhJc=
|
||||||
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
|
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 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
|
||||||
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
|
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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
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=
|
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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package client_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"garmin-connect/internal/api/client"
|
"go-garth/internal/api/client"
|
||||||
"garmin-connect/internal/auth/credentials"
|
"go-garth/internal/auth/credentials"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/internal/errors"
|
"go-garth/internal/errors"
|
||||||
"garmin-connect/internal/auth/sso"
|
"go-garth/internal/auth/sso"
|
||||||
"garmin-connect/internal/types"
|
"go-garth/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client represents the Garmin Connect API client
|
// Client represents the Garmin Connect API client
|
||||||
@@ -423,6 +423,244 @@ func (c *Client) GetActivities(limit int) ([]types.Activity, error) {
|
|||||||
return activities, nil
|
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
|
// SaveSession saves the current session to a file
|
||||||
func (c *Client) SaveSession(filename string) error {
|
func (c *Client) SaveSession(filename string) error {
|
||||||
session := types.SessionData{
|
session := types.SessionData{
|
||||||
@@ -480,4 +718,10 @@ func (c *Client) LoadSession(filename string) error {
|
|||||||
c.AuthToken = session.AuthToken
|
c.AuthToken = session.AuthToken
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshSession refreshes the authentication tokens
|
||||||
|
func (c *Client) RefreshSession() error {
|
||||||
|
// TODO: Implement token refresh logic
|
||||||
|
return fmt.Errorf("RefreshSession not implemented")
|
||||||
}
|
}
|
||||||
@@ -7,12 +7,12 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/internal/testutils"
|
"go-garth/internal/testutils"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"garmin-connect/internal/api/client"
|
"go-garth/internal/api/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestClient_GetUserProfile(t *testing.T) {
|
func TestClient_GetUserProfile(t *testing.T) {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/internal/types"
|
"go-garth/internal/types"
|
||||||
"garmin-connect/internal/utils"
|
"go-garth/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetOAuth1Token retrieves an OAuth1 token using the provided ticket
|
// GetOAuth1Token retrieves an OAuth1 token using the provided ticket
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/internal/auth/oauth"
|
"go-garth/internal/auth/oauth"
|
||||||
"garmin-connect/internal/types"
|
"go-garth/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/internal/api/client"
|
"go-garth/internal/api/client"
|
||||||
"garmin-connect/internal/utils"
|
"go-garth/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Data defines the interface for Garmin Connect data types.
|
// Data defines the interface for Garmin Connect data types.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/internal/api/client"
|
"go-garth/internal/api/client"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/internal/api/client"
|
"go-garth/internal/api/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DailyBodyBatteryStress represents complete daily Body Battery and stress data
|
// DailyBodyBatteryStress represents complete daily Body Battery and stress data
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/internal/api/client"
|
"go-garth/internal/api/client"
|
||||||
"garmin-connect/internal/utils"
|
"go-garth/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HRVSummary represents Heart Rate Variability summary data
|
// HRVSummary represents Heart Rate Variability summary data
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/internal/api/client"
|
"go-garth/internal/api/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SleepScores represents sleep scoring data
|
// SleepScores represents sleep scoring data
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/internal/api/client"
|
"go-garth/internal/api/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WeightData represents weight measurement data
|
// WeightData represents weight measurement data
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/internal/api/client"
|
"go-garth/internal/api/client"
|
||||||
"garmin-connect/internal/utils"
|
"go-garth/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Stats interface {
|
type Stats interface {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"garmin-connect/internal/api/client"
|
"go-garth/internal/api/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockClient simulates API client for tests
|
// MockClient simulates API client for tests
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ type Activity struct {
|
|||||||
ActivityID int64 `json:"activityId"`
|
ActivityID int64 `json:"activityId"`
|
||||||
ActivityName string `json:"activityName"`
|
ActivityName string `json:"activityName"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
StartTimeLocal string `json:"startTimeLocal"`
|
StartTimeLocal GarminTime `json:"startTimeLocal"`
|
||||||
StartTimeGMT string `json:"startTimeGMT"`
|
StartTimeGMT GarminTime `json:"startTimeGMT"`
|
||||||
ActivityType ActivityType `json:"activityType"`
|
ActivityType ActivityType `json:"activityType"`
|
||||||
EventType EventType `json:"eventType"`
|
EventType EventType `json:"eventType"`
|
||||||
Distance float64 `json:"distance"`
|
Distance float64 `json:"distance"`
|
||||||
@@ -89,3 +89,95 @@ type UserProfile struct {
|
|||||||
LevelUpdateDate GarminTime `json:"levelUpdateDate"`
|
LevelUpdateDate GarminTime `json:"levelUpdateDate"`
|
||||||
// Add other fields as needed from API response
|
// 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"`
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package users
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/internal/api/client"
|
"go-garth/internal/api/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PowerFormat struct {
|
type PowerFormat struct {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -36,3 +37,29 @@ func ToLocalTime(utcTime time.Time) time.Time {
|
|||||||
func ToUTCTime(localTime time.Time) time.Time {
|
func ToUTCTime(localTime time.Time) time.Time {
|
||||||
return localTime.UTC()
|
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{}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"garmin-connect/internal/types"
|
"go-garth/internal/types"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|||||||
6
main.go
6
main.go
@@ -5,9 +5,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/internal/api/client"
|
"go-garth/internal/api/client"
|
||||||
"garmin-connect/internal/auth/credentials"
|
"go-garth/internal/auth/credentials"
|
||||||
types "garmin-connect/pkg/garmin"
|
types "go-garth/pkg/garmin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
32
phase1.md
32
phase1.md
@@ -370,17 +370,17 @@ type VO2MaxReading struct {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Tasks:**
|
**Tasks:**
|
||||||
- [ ] Research VO2 max API endpoints
|
- [x] Research VO2 max API endpoints
|
||||||
- [ ] Implement data fetching
|
- [x] Implement data fetching
|
||||||
- [ ] Add historical data support
|
- [x] Add historical data support
|
||||||
- [ ] Create CLI command
|
- [x] Create CLI command
|
||||||
- [ ] Add data validation
|
- [x] Add data validation
|
||||||
- [ ] Format output appropriately
|
- [x] Format output appropriately
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- [ ] `garth health vo2max` command working
|
- [x] `garth health vo2max` command working
|
||||||
- [ ] Historical data support
|
- [x] Historical data support
|
||||||
- [ ] Both running and cycling metrics
|
- [x] Both running and cycling metrics
|
||||||
|
|
||||||
#### 1D.2: Heart Rate Zones
|
#### 1D.2: Heart Rate Zones
|
||||||
**Duration: 1 day**
|
**Duration: 1 day**
|
||||||
@@ -403,15 +403,15 @@ type HRZone struct {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Tasks:**
|
**Tasks:**
|
||||||
- [ ] Implement HR zones API calls
|
- [x] Implement HR zones API calls
|
||||||
- [ ] Add zone calculation logic
|
- [x] Add zone calculation logic
|
||||||
- [ ] Create CLI command
|
- [x] Create CLI command
|
||||||
- [ ] Add zone analysis features
|
- [x] Add zone analysis features
|
||||||
- [ ] Implement zone updates (if possible)
|
- [x] Implement zone updates (if possible)
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- [ ] `garth health hr-zones` command
|
- [x] `garth health hr-zones` command
|
||||||
- [ ] Zone calculation and display
|
- [x] Zone calculation and display
|
||||||
- [ ] Integration with other health metrics
|
- [ ] Integration with other health metrics
|
||||||
|
|
||||||
#### 1D.3: Additional Health Metrics
|
#### 1D.3: Additional Health Metrics
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ package garmin_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"garmin-connect/internal/api/client"
|
"go-garth/internal/api/client"
|
||||||
"garmin-connect/internal/data"
|
"go-garth/internal/data"
|
||||||
"garmin-connect/internal/testutils"
|
"go-garth/internal/testutils"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ package garmin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
internalClient "garmin-connect/internal/api/client"
|
internalClient "go-garth/internal/api/client"
|
||||||
"garmin-connect/internal/types"
|
"go-garth/internal/errors"
|
||||||
"garmin-connect/pkg/garmin/activities"
|
"go-garth/internal/types"
|
||||||
"garmin-connect/pkg/garmin/health"
|
|
||||||
"garmin-connect/pkg/garmin/stats"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client is the main Garmin Connect client type
|
// Client is the main Garmin Connect client type
|
||||||
@@ -22,7 +22,7 @@ func NewClient(domain string) (*Client, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &Client{Client: c},
|
return &Client{Client: c}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login authenticates to Garmin Connect
|
// Login authenticates to Garmin Connect
|
||||||
@@ -45,8 +45,8 @@ func (c *Client) RefreshSession() error {
|
|||||||
return c.Client.RefreshSession()
|
return c.Client.RefreshSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetActivities retrieves recent activities
|
// ListActivities retrieves recent activities
|
||||||
func (c *Client) GetActivities(opts activities.ActivityOptions) ([]Activity, error) {
|
func (c *Client) ListActivities(opts ActivityOptions) ([]Activity, error) {
|
||||||
// TODO: Map ActivityOptions to internalClient.Client.GetActivities parameters
|
// TODO: Map ActivityOptions to internalClient.Client.GetActivities parameters
|
||||||
// For now, just call the internal client's GetActivities with a dummy limit
|
// For now, just call the internal client's GetActivities with a dummy limit
|
||||||
internalActivities, err := c.Client.GetActivities(opts.Limit)
|
internalActivities, err := c.Client.GetActivities(opts.Limit)
|
||||||
@@ -60,7 +60,7 @@ func (c *Client) GetActivities(opts activities.ActivityOptions) ([]Activity, err
|
|||||||
ActivityID: act.ActivityID,
|
ActivityID: act.ActivityID,
|
||||||
ActivityName: act.ActivityName,
|
ActivityName: act.ActivityName,
|
||||||
ActivityType: act.ActivityType,
|
ActivityType: act.ActivityType,
|
||||||
Starttime: act.Starttime,
|
StartTimeLocal: act.StartTimeLocal,
|
||||||
Distance: act.Distance,
|
Distance: act.Distance,
|
||||||
Duration: act.Duration,
|
Duration: act.Duration,
|
||||||
})
|
})
|
||||||
@@ -69,13 +69,13 @@ func (c *Client) GetActivities(opts activities.ActivityOptions) ([]Activity, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetActivity retrieves details for a specific activity ID
|
// 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
|
// TODO: Implement internalClient.Client.GetActivity
|
||||||
return nil, fmt.Errorf("not implemented")
|
return nil, fmt.Errorf("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadActivity downloads activity data
|
// 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
|
// TODO: Determine file extension based on format
|
||||||
fileExtension := opts.Format
|
fileExtension := opts.Format
|
||||||
if fileExtension == "csv" {
|
if fileExtension == "csv" {
|
||||||
@@ -133,45 +133,53 @@ func (c *Client) SearchActivities(query string) ([]Activity, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetSleepData retrieves sleep data for a specified date range
|
// GetSleepData retrieves sleep data for a specified date range
|
||||||
func (c *Client) GetSleepData(startDate, endDate time.Time) ([]health.SleepData, error) {
|
func (c *Client) GetSleepData(startDate, endDate time.Time) ([]types.SleepData, error) {
|
||||||
// TODO: Implement internalClient.Client.GetSleepData
|
return c.Client.GetSleepData(startDate, endDate)
|
||||||
return nil, fmt.Errorf("not implemented")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHrvData retrieves HRV data for a specified number of days
|
// GetHrvData retrieves HRV data for a specified number of days
|
||||||
func (c *Client) GetHrvData(days int) ([]health.HrvData, error) {
|
func (c *Client) GetHrvData(days int) ([]types.HrvData, error) {
|
||||||
// TODO: Implement internalClient.Client.GetHrvData
|
return c.Client.GetHrvData(days)
|
||||||
return nil, fmt.Errorf("not implemented")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStressData retrieves stress data
|
// GetStressData retrieves stress data
|
||||||
func (c *Client) GetStressData(startDate, endDate time.Time) ([]health.StressData, error) {
|
func (c *Client) GetStressData(startDate, endDate time.Time) ([]types.StressData, error) {
|
||||||
// TODO: Implement internalClient.Client.GetStressData
|
return c.Client.GetStressData(startDate, endDate)
|
||||||
return nil, fmt.Errorf("not implemented")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBodyBatteryData retrieves Body Battery data
|
// GetBodyBatteryData retrieves Body Battery data
|
||||||
func (c *Client) GetBodyBatteryData(startDate, endDate time.Time) ([]health.BodyBatteryData, error) {
|
func (c *Client) GetBodyBatteryData(startDate, endDate time.Time) ([]types.BodyBatteryData, error) {
|
||||||
// TODO: Implement internalClient.Client.GetBodyBatteryData
|
return c.Client.GetBodyBatteryData(startDate, endDate)
|
||||||
return nil, fmt.Errorf("not implemented")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStepsData retrieves steps data for a specified date range
|
// GetStepsData retrieves steps data for a specified date range
|
||||||
func (c *Client) GetStepsData(startDate, endDate time.Time) ([]stats.StepsData, error) {
|
func (c *Client) GetStepsData(startDate, endDate time.Time) ([]types.StepsData, error) {
|
||||||
// TODO: Implement internalClient.Client.GetStepsData
|
return c.Client.GetStepsData(startDate, endDate)
|
||||||
return nil, fmt.Errorf("not implemented")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDistanceData retrieves distance data for a specified date range
|
// GetDistanceData retrieves distance data for a specified date range
|
||||||
func (c *Client) GetDistanceData(startDate, endDate time.Time) ([]stats.DistanceData, error) {
|
func (c *Client) GetDistanceData(startDate, endDate time.Time) ([]types.DistanceData, error) {
|
||||||
// TODO: Implement internalClient.Client.GetDistanceData
|
return c.Client.GetDistanceData(startDate, endDate)
|
||||||
return nil, fmt.Errorf("not implemented")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCaloriesData retrieves calories data for a specified date range
|
// GetCaloriesData retrieves calories data for a specified date range
|
||||||
func (c *Client) GetCaloriesData(startDate, endDate time.Time) ([]stats.CaloriesData, error) {
|
func (c *Client) GetCaloriesData(startDate, endDate time.Time) ([]types.CaloriesData, error) {
|
||||||
// TODO: Implement internalClient.Client.GetCaloriesData
|
return c.Client.GetCaloriesData(startDate, endDate)
|
||||||
return nil, fmt.Errorf("not implemented")
|
}
|
||||||
|
|
||||||
|
// 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
|
// OAuth1Token returns the OAuth1 token
|
||||||
|
|||||||
@@ -38,4 +38,42 @@ type BodyBatteryData struct {
|
|||||||
Charge int `json:"charge"`
|
Charge int `json:"charge"`
|
||||||
Drain int `json:"drain"`
|
Drain int `json:"drain"`
|
||||||
// Add more fields as needed
|
// 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
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/internal/api/client"
|
"go-garth/internal/api/client"
|
||||||
"garmin-connect/internal/data"
|
"go-garth/internal/data"
|
||||||
"garmin-connect/internal/stats"
|
"go-garth/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBodyBatteryIntegration(t *testing.T) {
|
func TestBodyBatteryIntegration(t *testing.T) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package garmin
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/internal/stats"
|
"go-garth/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Stats is an interface for stats data types.
|
// Stats is an interface for stats data types.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package garmin
|
package garmin
|
||||||
|
|
||||||
import "garmin-connect/internal/types"
|
import "go-garth/internal/types"
|
||||||
|
|
||||||
// GarminTime represents Garmin's timestamp format with custom JSON parsing
|
// GarminTime represents Garmin's timestamp format with custom JSON parsing
|
||||||
type GarminTime = types.GarminTime
|
type GarminTime = types.GarminTime
|
||||||
|
|||||||
Reference in New Issue
Block a user