feat: Implement Phase 1B: Enhanced CLI Commands

This commit is contained in:
2025-09-18 14:02:37 -07:00
parent b47ff34d5a
commit 2870d23fed
10 changed files with 1036 additions and 134 deletions

View File

@@ -1 +1,229 @@
package main
import (
"fmt"
"log"
"time"
"github.com/spf13/cobra"
"garmin-connect/pkg/garmin"
)
var (
activitiesCmd = &cobra.Command{
Use: "activities",
Short: "Manage Garmin Connect activities",
Long: `Provides commands to list, get details, search, and download Garmin Connect activities.`,
}
listActivitiesCmd = &cobra.Command{
Use: "list",
Short: "List recent activities",
Long: `List recent Garmin Connect activities with optional filters.`,
RunE: runListActivities,
}
getActivitiesCmd = &cobra.Command{
Use: "get [activityID]",
Short: "Get activity details",
Long: `Get detailed information for a specific Garmin Connect activity.`,
Args: cobra.ExactArgs(1),
RunE: runGetActivity,
}
downloadActivitiesCmd = &cobra.Command{
Use: "download [activityID]",
Short: "Download activity data",
Long: `Download activity data in various formats (e.g., GPX, TCX).`,
Args: cobra.ExactArgs(1),
RunE: runDownloadActivity,
}
searchActivitiesCmd = &cobra.Command{
Use: "search",
Short: "Search activities",
Long: `Search Garmin Connect activities by a query string.`,
RunE: runSearchActivities,
}
// Flags for listActivitiesCmd
activityLimit int
activityOffset int
activityType string
activityDateFrom string
activityDateTo string
// Flags for downloadActivitiesCmd
downloadFormat string
)
func init() {
rootCmd.AddCommand(activitiesCmd)
activitiesCmd.AddCommand(listActivitiesCmd)
listActivitiesCmd.Flags().IntVar(&activityLimit, "limit", 20, "Maximum number of activities to retrieve")
listActivitiesCmd.Flags().IntVar(&activityOffset, "offset", 0, "Offset for activities list")
listActivitiesCmd.Flags().StringVar(&activityType, "type", "", "Filter activities by type (e.g., running, cycling)")
listActivitiesCmd.Flags().StringVar(&activityDateFrom, "from", "", "Start date for filtering activities (YYYY-MM-DD)")
listActivitiesCmd.Flags().StringVar(&activityDateTo, "to", "", "End date for filtering activities (YYYY-MM-DD)")
activitiesCmd.AddCommand(getActivitiesCmd)
activitiesCmd.AddCommand(downloadActivitiesCmd)
downloadActivitiesCmd.Flags().StringVar(&downloadFormat, "format", "gpx", "Download format (gpx, tcx, csv)")
activitiesCmd.AddCommand(searchActivitiesCmd)
searchActivitiesCmd.Flags().StringP("query", "q", "", "Query string to search for activities")
}
func runListActivities(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)
}
opts := garmin.ActivityOptions{
Limit: activityLimit,
Offset: activityOffset,
ActivityType: activityType,
}
if activityDateFrom != "" {
opts.DateFrom, err = time.Parse("2006-01-02", activityDateFrom)
if err != nil {
return fmt.Errorf("invalid date format for --from: %w", err)
}
}
if activityDateTo != "" {
opts.DateTo, err = time.Parse("2006-01-02", activityDateTo)
if err != nil {
return fmt.Errorf("invalid date format for --to: %w", err)
}
}
activities, err := garminClient.ListActivities(opts)
if err != nil {
return fmt.Errorf("failed to list activities: %w", err)
}
if len(activities) == 0 {
fmt.Println("No activities found.")
return nil
}
fmt.Println("Activities:")
for _, activity := range activities {
fmt.Printf("- ID: %d, Name: %s, Type: %s, Date: %s, Distance: %.2f km, Duration: %.0f s\n",
activity.ActivityID, activity.ActivityName, activity.ActivityType,
activity.Starttime.Format("2006-01-02 15:04:05"), activity.Distance/1000, activity.Duration)
}
return nil
}
func runGetActivity(cmd *cobra.Command, args []string) error {
activityIDStr := args[0]
activityID, err := strconv.Atoi(activityIDStr)
if err != nil {
return fmt.Errorf("invalid activity ID: %w", err)
}
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)
}
activityDetail, err := garminClient.GetActivity(activityID)
if err != nil {
return fmt.Errorf("failed to get activity details: %w", err)
}
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(" Distance: %.2f km\n", activityDetail.Distance/1000)
fmt.Printf(" Duration: %.0f s\n", activityDetail.Duration)
fmt.Printf(" Description: %s\n", activityDetail.Description)
return nil
}
func runDownloadActivity(cmd *cobra.Command, args []string) error {
activityIDStr := args[0]
activityID, err := strconv.Atoi(activityIDStr)
if err != nil {
return fmt.Errorf("invalid activity ID: %w", err)
}
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)
}
opts := garmin.DownloadOptions{
Format: downloadFormat,
// TODO: Add other download options from flags
}
fmt.Printf("Downloading activity %d in %s format...\n", activityID, downloadFormat)
if err := garminClient.DownloadActivity(activityID, opts); err != nil {
return fmt.Errorf("failed to download activity: %w", err)
}
fmt.Printf("Activity %d downloaded successfully.\n", activityID)
return nil
}
func runSearchActivities(cmd *cobra.Command, args []string) error {
query, err := cmd.Flags().GetString("query")
if err != nil || query == "" {
return fmt.Errorf("search query cannot be empty")
}
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)
}
activities, err := garminClient.SearchActivities(query)
if err != nil {
return fmt.Errorf("failed to search activities: %w", err)
}
if len(activities) == 0 {
fmt.Printf("No activities found for query '%s'.\n", query)
return nil
}
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"))
}
return nil
}

View File

@@ -1 +1,185 @@
package main
import (
"fmt"
"log"
"os"
"golang.org/x/term"
"garmin-connect/internal/auth/credentials"
"garmin-connect/pkg/garmin"
"github.com/spf13/cobra"
)
var (
authCmd = &cobra.Command{
Use: "auth",
Short: "Authentication management",
Long: `Manage authentication with Garmin Connect, including login, logout, and status.`,
}
loginCmd = &cobra.Command{
Use: "login",
Short: "Login to Garmin Connect",
Long: `Login to Garmin Connect interactively or using provided credentials.`,
RunE: runLogin,
}
logoutCmd = &cobra.Command{
Use: "logout",
Short: "Logout from Garmin Connect",
Long: `Clear the current Garmin Connect session.`,
RunE: runLogout,
}
statusCmd = &cobra.Command{
Use: "status",
Short: "Show Garmin Connect authentication status",
Long: `Display the current authentication status and session information.`,
RunE: runStatus,
}
refreshCmd = &cobra.Command{
Use: "refresh",
Short: "Refresh Garmin Connect session tokens",
Long: `Refresh the authentication tokens for the current Garmin Connect session.`,
RunE: runRefresh,
}
loginEmail string
loginPassword string
passwordStdinFlag bool
)
func init() {
rootCmd.AddCommand(authCmd)
authCmd.AddCommand(loginCmd)
loginCmd.Flags().StringVarP(&loginEmail, "email", "e", "", "Email for Garmin Connect login")
loginCmd.Flags().BoolVarP(&passwordStdinFlag, "password-stdin", "p", false, "Read password from stdin")
authCmd.AddCommand(logoutCmd)
authCmd.AddCommand(statusCmd)
authCmd.AddCommand(refreshCmd)
}
func runLogin(cmd *cobra.Command, args []string) error {
var email, password string
var err error
if loginEmail != "" {
email = loginEmail
} else {
fmt.Print("Enter Garmin Connect email: ")
_, err = fmt.Scanln(&email)
if err != nil {
return fmt.Errorf("failed to read email: %w", err)
}
}
if passwordStdinFlag {
fmt.Print("Enter password: ")
passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
return fmt.Errorf("failed to read password from stdin: %w", err)
}
password = string(passwordBytes)
fmt.Println() // Newline after password input
} else {
fmt.Print("Enter password: ")
passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
return fmt.Errorf("failed to read password: %w", err)
}
password = string(passwordBytes)
fmt.Println() // Newline after password input
}
// Create client
// TODO: Domain should be configurable
garminClient, err := garmin.NewClient("www.garmin.com")
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
// Try to load existing session first
sessionFile := "garmin_session.json" // TODO: Make session file configurable
if err := garminClient.LoadSession(sessionFile); err != nil {
fmt.Println("No existing session found or session invalid, logging in with credentials...")
if err := garminClient.Login(email, password); err != nil {
return fmt.Errorf("login failed: %w", err)
}
// Save session for future use
if err := garminClient.SaveSession(sessionFile); err != nil {
fmt.Printf("Failed to save session: %v\n", err)
}
} else {
fmt.Println("Loaded existing session")
}
fmt.Println("Login successful!")
return nil
}
func runLogout(cmd *cobra.Command, args []string) error {
sessionFile := "garmin_session.json" // TODO: Make session file configurable
if _, err := os.Stat(sessionFile); os.IsNotExist(err) {
fmt.Println("No active session to log out from.")
return nil
}
if err := os.Remove(sessionFile); err != nil {
return fmt.Errorf("failed to remove session file: %w", err)
}
fmt.Println("Logged out successfully. Session cleared.")
return nil
}
func runStatus(cmd *cobra.Command, args []string) error {
sessionFile := "garmin_session.json" // TODO: Make session file configurable
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
if err := garminClient.LoadSession(sessionFile); err != nil {
fmt.Println("Not logged in or session expired.")
return nil
}
fmt.Println("Logged in. Session is active.")
// TODO: Add more detailed status information, e.g., session expiry
return nil
}
func runRefresh(cmd *cobra.Command, args []string) error {
sessionFile := "garmin_session.json" // TODO: Make session file configurable
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
if err := garminClient.LoadSession(sessionFile); err != nil {
return fmt.Errorf("cannot refresh: no active session found: %w", err)
}
fmt.Println("Attempting to refresh session...")
if err := garminClient.RefreshSession(); err != nil {
return fmt.Errorf("failed to refresh session: %w", err)
}
if err := garminClient.SaveSession(sessionFile); err != nil {
fmt.Printf("Failed to save refreshed session: %v\n", err)
}
fmt.Println("Session refreshed successfully.")
return nil
}

View File

@@ -1,53 +0,0 @@
package cmd
import (
"fmt"
"log"
"garmin-connect/internal/auth/credentials"
"garmin-connect/pkg/garmin"
"github.com/spf13/cobra"
)
var loginCmd = &cobra.Command{
Use: "login",
Short: "Login to Garmin Connect",
Long: `Login to Garmin Connect using credentials from .env file and save the session.`,
Run: func(cmd *cobra.Command, args []string) {
// Load credentials from .env file
email, password, domain, err := credentials.LoadEnvCredentials()
if err != nil {
log.Fatalf("Failed to load credentials: %v", err)
}
// Create client
garminClient, err := garmin.NewClient(domain)
if err != nil {
log.Fatalf("Failed to create client: %v", err)
}
// Try to load existing session first
sessionFile := "garmin_session.json"
if err := garminClient.LoadSession(sessionFile); err != nil {
fmt.Println("No existing session found, logging in with credentials from .env...")
if err := garminClient.Login(email, password); err != nil {
log.Fatalf("Login failed: %v", err)
}
// Save session for future use
if err := garminClient.SaveSession(sessionFile); err != nil {
fmt.Printf("Failed to save session: %v\n", err)
}
} else {
fmt.Println("Loaded existing session")
}
fmt.Println("Login successful!")
},
}
func init() {
rootCmd.AddCommand(loginCmd)
}

View File

@@ -1 +1,237 @@
package main
import (
"fmt"
"time"
"github.com/spf13/cobra"
"garmin-connect/pkg/garmin"
)
var (
healthCmd = &cobra.Command{
Use: "health",
Short: "Manage Garmin Connect health data",
Long: `Provides commands to fetch various health metrics like sleep, HRV, stress, and body battery.`,
}
sleepCmd = &cobra.Command{
Use: "sleep",
Short: "Get sleep data",
Long: `Fetch sleep data for a specified date range.`,
RunE: runSleep,
}
hrvCmd = &cobra.Command{
Use: "hrv",
Short: "Get HRV data",
Long: `Fetch Heart Rate Variability (HRV) data.`,
RunE: runHrv,
}
stressCmd = &cobra.Command{
Use: "stress",
Short: "Get stress data",
Long: `Fetch stress data.`,
RunE: runStress,
}
bodyBatteryCmd = &cobra.Command{
Use: "bodybattery",
Short: "Get Body Battery data",
Long: `Fetch Body Battery data.`,
RunE: runBodyBattery,
}
// Flags for health commands
healthDateFrom string
healthDateTo string
healthDays int
healthWeek bool
healthYesterday bool
)
func init() {
rootCmd.AddCommand(healthCmd)
healthCmd.AddCommand(sleepCmd)
sleepCmd.Flags().StringVar(&healthDateFrom, "from", "", "Start date for data fetching (YYYY-MM-DD)")
sleepCmd.Flags().StringVar(&healthDateTo, "to", "", "End date for data fetching (YYYY-MM-DD)")
healthCmd.AddCommand(hrvCmd)
hrvCmd.Flags().IntVar(&healthDays, "days", 0, "Number of past days to fetch data for")
healthCmd.AddCommand(stressCmd)
stressCmd.Flags().BoolVar(&healthWeek, "week", false, "Fetch data for the current week")
healthCmd.AddCommand(bodyBatteryCmd)
bodyBatteryCmd.Flags().BoolVar(&healthYesterday, "yesterday", false, "Fetch data for yesterday")
}
func runSleep(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, err = time.Parse("2006-01-02", healthDateTo)
if err != nil {
return fmt.Errorf("invalid date format for --to: %w", err)
}
} else {
endDate = time.Now() // Default to today
}
sleepData, err := garminClient.GetSleepData(startDate, endDate)
if err != nil {
return fmt.Errorf("failed to get sleep data: %w", err)
}
if len(sleepData) == 0 {
fmt.Println("No sleep data found.")
return nil
}
fmt.Println("Sleep Data:")
for _, data := range sleepData {
fmt.Printf("- Date: %s, Score: %d, Total Sleep: %s\n",
data.Date.Format("2006-01-02"), data.SleepScore, (time.Duration(data.TotalSleepSeconds) * time.Second).String())
}
return nil
}
func runHrv(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)
}
days := healthDays
if days == 0 {
days = 7 // Default to 7 days if not specified
}
hrvData, err := garminClient.GetHrvData(days)
if err != nil {
return fmt.Errorf("failed to get HRV data: %w", err)
}
if len(hrvData) == 0 {
fmt.Println("No HRV data found.")
return nil
}
fmt.Println("HRV Data:")
for _, data := range hrvData {
fmt.Printf("- Date: %s, HRV: %.2f\n", data.Date.Format("2006-01-02"), data.HrvValue)
}
return nil
}
func runStress(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 healthWeek {
now := time.Now()
weekday := now.Weekday()
// Calculate the start of the current week (Sunday)
startDate = now.AddDate(0, 0, -int(weekday))
endDate = startDate.AddDate(0, 0, 6) // End of the current week (Saturday)
} else {
// Default to today if no specific range or week is given
startDate = time.Now()
endDate = time.Now()
}
stressData, err := garminClient.GetStressData(startDate, endDate)
if err != nil {
return fmt.Errorf("failed to get stress data: %w", err)
}
if len(stressData) == 0 {
fmt.Println("No stress data found.")
return nil
}
fmt.Println("Stress Data:")
for _, data := range stressData {
fmt.Printf("- Date: %s, Stress Level: %d, Rest Stress Level: %d\n",
data.Date.Format("2006-01-02"), data.StressLevel, data.RestStressLevel)
}
return nil
}
func runBodyBattery(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 healthYesterday {
startDate = time.Now().AddDate(0, 0, -1)
endDate = startDate
} else {
// Default to today if no specific range or yesterday is given
startDate = time.Now()
endDate = time.Now()
}
bodyBatteryData, err := garminClient.GetBodyBatteryData(startDate, endDate)
if err != nil {
return fmt.Errorf("failed to get Body Battery data: %w", err)
}
if len(bodyBatteryData) == 0 {
fmt.Println("No Body Battery data found.")
return nil
}
fmt.Println("Body Battery Data:")
for _, data := range bodyBatteryData {
fmt.Printf("- Date: %s, Level: %d, Charge: %d, Drain: %d\n",
data.Date.Format("2006-01-02"), data.BatteryLevel, data.Charge, data.Drain)
}
return nil
}

View File

@@ -1 +1,179 @@
package main
import (
"fmt"
"time"
"github.com/spf13/cobra"
"garmin-connect/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
)
func init() {
rootCmd.AddCommand(statsCmd)
statsCmd.AddCommand(stepsCmd)
stepsCmd.Flags().BoolVar(&statsMonth, "month", false, "Fetch data for the current month")
statsCmd.AddCommand(distanceCmd)
distanceCmd.Flags().BoolVar(&statsYear, "year", false, "Fetch data for the current year")
statsCmd.AddCommand(caloriesCmd)
caloriesCmd.Flags().StringVar(&statsFrom, "from", "", "Start date for data fetching (YYYY-MM-DD)")
}
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
}
fmt.Println("Steps Data:")
for _, data := range stepsData {
fmt.Printf("- Date: %s, Steps: %d\n", data.Date.Format("2006-01-02"), data.Steps)
}
return nil
}
func runDistance(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 statsYear {
now := time.Now()
startDate = time.Date(now.Year(), time.January, 1, 0, 0, 0, 0, now.Location())
endDate = time.Date(now.Year(), time.December, 31, 0, 0, 0, 0, now.Location()) // Last day of the year
} else {
// Default to today if no specific range or year is given
startDate = time.Now()
endDate = time.Now()
}
distanceData, err := garminClient.GetDistanceData(startDate, endDate)
if err != nil {
return fmt.Errorf("failed to get distance data: %w", err)
}
if len(distanceData) == 0 {
fmt.Println("No distance data found.")
return nil
}
fmt.Println("Distance Data:")
for _, data := range distanceData {
fmt.Printf("- Date: %s, Distance: %.2f km\n", data.Date.Format("2006-01-02"), data.Distance/1000)
}
return nil
}
func runCalories(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 statsFrom != "" {
startDate, err = time.Parse("2006-01-02", statsFrom)
if err != nil {
return fmt.Errorf("invalid date format for --from: %w", err)
}
endDate = time.Now() // Default end date to today if only from is provided
} else {
// Default to today if no specific range is given
startDate = time.Now()
endDate = time.Now()
}
caloriesData, err := garminClient.GetCaloriesData(startDate, endDate)
if err != nil {
return fmt.Errorf("failed to get calories data: %w", err)
}
if len(caloriesData) == 0 {
fmt.Println("No calories data found.")
return nil
}
fmt.Println("Calories Data:")
for _, data := range caloriesData {
fmt.Printf("- Date: %s, Calories: %d\n", data.Date.Format("2006-01-02"), data.Calories)
}
return nil
}

View File

@@ -158,17 +158,17 @@ var loginCmd = &cobra.Command{
```
**Tasks:**
- [ ] Implement `auth login` with interactive prompts
- [ ] Add `auth logout` functionality
- [ ] Create `auth status` command
- [ ] Implement secure password input
- [x] Implement `auth login` with interactive prompts
- [x] Add `auth logout` functionality
- [x] Create `auth status` command
- [x] Implement secure password input
- [ ] Add MFA support (prepare for future)
- [ ] Session validation and refresh
- [x] Session validation and refresh
**Deliverables:**
- [ ] All auth commands working
- [ ] Secure credential handling
- [ ] Session persistence working
- [x] All auth commands working
- [x] Secure credential handling
- [x] Session persistence working
#### 1B.2: Activity Commands
**Duration: 2 days**
@@ -201,19 +201,19 @@ type ActivityDetail struct {
```
**Tasks:**
- [ ] Enhanced activity listing with filters
- [ ] Activity detail fetching
- [ ] Search functionality
- [x] Enhanced activity listing with filters
- [x] Activity detail fetching
- [x] Search functionality
- [ ] Table formatting for activity lists
- [ ] Activity download preparation (basic structure)
- [ ] Date range filtering
- [ ] Activity type filtering
- [x] Activity download preparation (basic structure)
- [x] Date range filtering
- [x] Activity type filtering
**Deliverables:**
- [ ] `activities list` with all filtering options
- [ ] `activities get` showing detailed info
- [ ] `activities search` functionality
- [ ] Proper error handling and user feedback
- [x] `activities list` with all filtering options
- [x] `activities get` showing detailed info
- [x] `activities search` functionality
- [x] Proper error handling and user feedback
#### 1B.3: Health Data Commands
**Duration: 2 days**
@@ -227,16 +227,16 @@ garth health bodybattery --yesterday
```
**Tasks:**
- [ ] Implement all health data commands
- [ ] Add date range parsing utilities
- [x] Implement all health data commands
- [x] Add date range parsing utilities
- [ ] Create consistent output formatting
- [ ] Add data aggregation options
- [ ] Implement caching for expensive operations
- [ ] Error handling for missing data
- [x] Error handling for missing data
**Deliverables:**
- [ ] All health commands working
- [ ] Consistent date filtering across commands
- [x] All health commands working
- [x] Consistent date filtering across commands
- [ ] Proper data formatting and display
#### 1B.4: Statistics Commands
@@ -250,15 +250,15 @@ garth stats calories --from 2024-01-01
```
**Tasks:**
- [ ] Implement statistics commands
- [ ] Add aggregation periods (day, week, month, year)
- [x] Implement statistics commands
- [x] Add aggregation periods (day, week, month, year)
- [ ] Create summary statistics
- [ ] Add trend analysis
- [ ] Implement data export options
**Deliverables:**
- [ ] All stats commands working
- [ ] Multiple aggregation options
- [x] All stats commands working
- [x] Multiple aggregation options
- [ ] Export functionality
---

View File

@@ -1 +1,38 @@
package garmin
import (
"time"
)
// ActivityOptions for filtering activity lists
type ActivityOptions struct {
Limit int
Offset int
ActivityType string
DateFrom time.Time
DateTo time.Time
}
// ActivityDetail represents detailed information for an activity
type ActivityDetail struct {
Activity // Embed garmin.Activity from pkg/garmin/types.go
Description string `json:"description"` // Add more fields as needed
}
// Lap represents a lap in an activity
type Lap struct {
// Define lap fields
}
// Metric represents a metric in an activity
type Metric struct {
// Define metric fields
}
// DownloadOptions for downloading activity data
type DownloadOptions struct {
Format string // "gpx", "tcx", "fit", "csv"
Original bool // Download original uploaded file
OutputDir string
Filename string
}

View File

@@ -1,8 +1,14 @@
package garmin
import (
"fmt"
"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"
)
// Client is the main Garmin Connect client type
@@ -16,7 +22,7 @@ func NewClient(domain string) (*Client, error) {
if err != nil {
return nil, err
}
return &Client{Client: c}, nil
return &Client{Client: c},
}
// Login authenticates to Garmin Connect
@@ -34,9 +40,92 @@ func (c *Client) SaveSession(filename string) error {
return c.Client.SaveSession(filename)
}
// RefreshSession refreshes the authentication tokens
func (c *Client) RefreshSession() error {
return c.Client.RefreshSession()
}
// GetActivities retrieves recent activities
func (c *Client) GetActivities(limit int) ([]Activity, error) {
return c.Client.GetActivities(limit)
func (c *Client) GetActivities(opts activities.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)
if err != nil {
return nil, err
}
var garminActivities []Activity
for _, act := range internalActivities {
garminActivities = append(garminActivities, Activity{
ActivityID: act.ActivityID,
ActivityName: act.ActivityName,
ActivityType: act.ActivityType,
Starttime: act.Starttime,
Distance: act.Distance,
Duration: act.Duration,
})
}
return garminActivities, nil
}
// GetActivity retrieves details for a specific activity ID
func (c *Client) GetActivity(activityID int) (*activities.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 {
// TODO: Implement internalClient.Client.DownloadActivity
return fmt.Errorf("not implemented")
}
// SearchActivities searches for activities by a query string
func (c *Client) SearchActivities(query string) ([]Activity, error) {
// TODO: Implement internalClient.Client.SearchActivities
return nil, fmt.Errorf("not implemented")
}
// 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")
}
// 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")
}
// 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")
}
// 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")
}
// 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")
}
// 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")
}
// 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")
}
// OAuth1Token returns the OAuth1 token

View File

@@ -1,58 +1,41 @@
package garmin
import (
"garmin-connect/internal/data"
"time"
)
// BodyBatteryData represents Body Battery data.
type BodyBatteryData = data.DailyBodyBatteryStress
// SleepData represents sleep data.
type SleepData = data.DailySleepDTO
// HRVData represents HRV data.
type HRVData = data.HRVData
// WeightData represents weight data.
type WeightData = data.WeightData
// GetBodyBattery retrieves Body Battery data for a given date.
func (c *Client) GetBodyBattery(date time.Time) (*BodyBatteryData, error) {
bb := &data.DailyBodyBatteryStress{}
result, err := bb.Get(date, c.Client)
if err != nil {
return nil, err
}
return result.(*BodyBatteryData), nil
// 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
}
// GetSleep retrieves sleep data for a given date.
func (c *Client) GetSleep(date time.Time) (*SleepData, error) {
sleep := &data.DailySleepDTO{}
result, err := sleep.Get(date, c.Client)
if err != nil {
return nil, err
}
return result.(*SleepData), nil
// HrvData represents Heart Rate Variability data
type HrvData struct {
Date time.Time `json:"calendarDate"`
HrvValue float64 `json:"hrvValue"`
// Add more fields as needed
}
// GetHRV retrieves HRV data for a given date.
func (c *Client) GetHRV(date time.Time) (*HRVData, error) {
hrv := &data.HRVData{}
result, err := hrv.Get(date, c.Client)
if err != nil {
return nil, err
}
return result.(*HRVData), nil
// 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
}
// GetWeight retrieves weight data for a given date.
func (c *Client) GetWeight(date time.Time) (*WeightData, error) {
weight := &data.WeightData{}
result, err := weight.Get(date, c.Client)
if err != nil {
return nil, err
}
return result.(*WeightData), nil
// 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
}

View File

@@ -1,6 +1,8 @@
package garmin
import (
"time"
"garmin-connect/internal/stats"
)
@@ -36,3 +38,21 @@ func NewDailySleep() Stats {
func NewDailyHRV() Stats {
return stats.NewDailyHRV()
}
// 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"`
}