diff --git a/fitness-tui/cmd/main.go b/fitness-tui/cmd/main.go index b51c488..d7509a8 100644 --- a/fitness-tui/cmd/main.go +++ b/fitness-tui/cmd/main.go @@ -4,8 +4,8 @@ import ( "fmt" "os" - "github.com/sstent/aicyclingcoach-go/internal/config" - "github.com/sstent/aicyclingcoach-go/internal/tui" + "github.com/sstent/fitness-tui/internal/config" + "github.com/sstent/fitness-tui/internal/tui" ) func main() { diff --git a/fitness-tui/go.mod b/fitness-tui/go.mod index b653318..8bba727 100644 --- a/fitness-tui/go.mod +++ b/fitness-tui/go.mod @@ -1,20 +1,18 @@ -module github.com/sstent/aicyclingcoach-go +module github.com/sstent/fitness-tui -go 1.24.0 - -toolchain go1.24.2 - -replace garmin-connect => github.com/sstent/go-garth v0.1.0 +go 1.24.2 require ( + github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.9 + github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/viper v1.21.0 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect @@ -31,6 +29,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -41,3 +40,9 @@ require ( golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.28.0 // indirect ) + +replace github.com/sstent/fitness-tui/internal/garmin => ./internal/garmin + +replace github.com/sstent/fitness-tui/internal/storage => ./internal/storage + +replace github.com/sstent/fitness-tui/internal/tui/models => ./internal/tui/models diff --git a/fitness-tui/go.sum b/fitness-tui/go.sum index 254744e..dea7998 100644 --- a/fitness-tui/go.sum +++ b/fitness-tui/go.sum @@ -1,5 +1,11 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.9 h1:OBYdfRo6QnlIcXNmcoI2n1NNS65Nk6kI2L2FO1puS/4= github.com/charmbracelet/bubbletea v1.3.9/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= @@ -10,6 +16,8 @@ github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7 github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -28,6 +36,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -53,6 +63,8 @@ github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZV github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= diff --git a/fitness-tui/internal/config/config.go b/fitness-tui/internal/config/config.go index b372b17..87caa1b 100644 --- a/fitness-tui/internal/config/config.go +++ b/fitness-tui/internal/config/config.go @@ -10,43 +10,69 @@ import ( type Config struct { Garmin struct { - Username string - Password string - } + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + } `mapstructure:"garmin"` OpenRouter struct { - APIKey string - Model string - } - StoragePath string + APIKey string `mapstructure:"apikey"` + Model string `mapstructure:"model"` + } `mapstructure:"openrouter"` + StoragePath string `mapstructure:"storagepath"` } func Load() (*Config, error) { - home, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("failed to get user home directory: %w", err) - } - viper.SetConfigName("config") viper.SetConfigType("yaml") - viper.AddConfigPath(filepath.Join(home, ".fitness-tui")) - viper.AddConfigPath(".") + setViperDefaults() + // Read configuration if err := viper.ReadInConfig(); err != nil { - return nil, fmt.Errorf("failed to read config: %w", err) + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, fmt.Errorf("config read error: %w", err) + } } - var cfg Config - if err := viper.Unmarshal(&cfg); err != nil { - return nil, fmt.Errorf("failed to unmarshal config: %w", err) + // Create storage path atomically + storagePath := viper.GetString("storagepath") + if err := os.MkdirAll(storagePath, 0755); err != nil { + return nil, fmt.Errorf("failed to create storage path: %w", err) } - // Set defaults if not configured - if cfg.StoragePath == "" { - cfg.StoragePath = filepath.Join(home, ".fitness-tui") - } - if cfg.OpenRouter.Model == "" { - cfg.OpenRouter.Model = "deepseek/deepseek-r1-0528" + cfg := new(Config) + if err := viper.Unmarshal(cfg); err != nil { + return nil, fmt.Errorf("config unmarshal error: %w", err) } - return &cfg, nil + if err := validateConfig(cfg); err != nil { + return nil, fmt.Errorf("config validation failed: %w", err) + } + + return cfg, nil +} + +func setViperDefaults() { + home, err := os.UserHomeDir() + if err != nil { + home = "." // Fallback to current directory + } + + viper.SetDefault("storagepath", filepath.Join(home, ".fitness-tui")) + viper.SetDefault("garmin.username", "") + viper.SetDefault("garmin.password", "") + viper.SetDefault("openrouter.apikey", "") + viper.SetDefault("openrouter.model", "deepseek/deepseek-r1-0528") +} + +func validateConfig(cfg *Config) error { + switch { + case cfg.Garmin.Username == "": + return fmt.Errorf("garmin.username required") + case cfg.Garmin.Password == "": + return fmt.Errorf("garmin.password required") + case cfg.OpenRouter.APIKey == "": + return fmt.Errorf("openrouter.apikey required") + } + return nil +} +} } diff --git a/fitness-tui/internal/config/defaults.go b/fitness-tui/internal/config/defaults.go index db8b130..c86ecf9 100644 --- a/fitness-tui/internal/config/defaults.go +++ b/fitness-tui/internal/config/defaults.go @@ -1,3 +1,9 @@ package config -// Default configuration values will be added here +const ( + DefaultSyncInterval = "24h" + DefaultPageSize = 50 + DefaultCacheTTL = "168h" // 1 week + DefaultGarminHost = "connect.garmin.com" + DefaultAnalysisModel = "deepseek/deepseek-r1-0528" +) diff --git a/fitness-tui/internal/garmin/auth.go b/fitness-tui/internal/garmin/auth.go index e69de29..20c3f4b 100644 --- a/fitness-tui/internal/garmin/auth.go +++ b/fitness-tui/internal/garmin/auth.go @@ -0,0 +1,87 @@ +package garmin + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/sstent/go-garth" + "gopkg.in/yaml.v3" +) + +const sessionTimeout = 30 * time.Minute + +type Auth struct { + client *garth.Client + username string + password string + sessionPath string +} + +type Session struct { + Cookies []*http.Cookie `yaml:"cookies"` + ExpiresAt time.Time `yaml:"expires_at"` +} + +func NewAuth(username, password, storagePath string) *Auth { + return &Auth{ + client: garth.New(), + username: username, + password: password, + sessionPath: filepath.Join(storagePath, "garmin_session.yaml"), + } +} + +func (a *Auth) Connect(ctx context.Context) error { + if sess, err := a.loadSession(); err == nil { + if time.Now().Before(sess.ExpiresAt) { + a.client.SetCookies(sess.Cookies) + return nil + } + } + return a.login(ctx) +} + +func (a *Auth) login(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, sessionTimeout) + defer cancel() + + for attempt := 1; attempt <= 3; attempt++ { + if err := a.client.Login(ctx, a.username, a.password); err == nil { + return a.saveSession() + } + time.Sleep(time.Duration(attempt*attempt) * time.Second) + } + return errors.New("authentication failed after 3 attempts") +} + +func (a *Auth) saveSession() error { + sess := Session{ + Cookies: a.client.Cookies(), + ExpiresAt: time.Now().Add(sessionTimeout), + } + + data, err := yaml.Marshal(sess) + if err != nil { + return fmt.Errorf("session marshal failed: %w", err) + } + + return os.WriteFile(a.sessionPath, data, 0600) +} + +func (a *Auth) loadSession() (*Session, error) { + data, err := os.ReadFile(a.sessionPath) + if err != nil { + return nil, err + } + + var sess Session + if err := yaml.Unmarshal(data, &sess); err != nil { + return nil, fmt.Errorf("session parse failed: %w", err) + } + return &sess, nil +} diff --git a/fitness-tui/internal/garmin/client.go b/fitness-tui/internal/garmin/client.go index e69de29..e90cbce 100644 --- a/fitness-tui/internal/garmin/client.go +++ b/fitness-tui/internal/garmin/client.go @@ -0,0 +1,53 @@ +package garmin + +import ( + "context" + "fmt" + + "github.com/sstent/fitness-tui/internal/tui/models" + "github.com/sstent/go-garth" +) + +type Client struct { + client *garth.Client + auth *Auth +} + +func NewClient(username, password, storagePath string) *Client { + return &Client{ + client: garth.New(), + auth: NewAuth(username, password, storagePath), + } +} + +func (c *Client) GetActivities(ctx context.Context, limit int) ([]*models.Activity, error) { + if err := c.auth.Connect(ctx); err != nil { + return nil, fmt.Errorf("authentication failed: %w", err) + } + + gActivities, err := c.client.GetActivities(ctx, 0, limit) + if err != nil { + return nil, fmt.Errorf("failed to fetch activities: %w", err) + } + + activities := make([]*models.Activity, 0, len(gActivities)) + for _, ga := range gActivities { + activities = append(activities, convertActivity(ga)) + } + + return activities, nil +} + +func convertActivity(ga *garth.Activity) *models.Activity { + return &models.Activity{ + ID: ga.ID, + Name: ga.Name, + Description: ga.Description, + Type: ga.Type, + StartTime: ga.StartTime, + Distance: ga.Distance, + Duration: ga.Duration, + Elevation: ga.Elevation, + HeartRate: ga.HeartRate, + } +} diff --git a/fitness-tui/internal/storage/activities.go b/fitness-tui/internal/storage/activities.go index 294f31c..487eb91 100644 --- a/fitness-tui/internal/storage/activities.go +++ b/fitness-tui/internal/storage/activities.go @@ -1,3 +1,137 @@ package storage -// Activity storage implementation will be added here +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/sstent/fitness-tui/internal/tui/models" +) + +type ActivityStorage struct { + dataDir string + lockPath string +} + +func NewActivityStorage(dataDir string) *ActivityStorage { + activitiesDir := filepath.Join(dataDir, "activities") + os.MkdirAll(activitiesDir, 0755) + + return &ActivityStorage{ + dataDir: dataDir, + lockPath: filepath.Join(dataDir, "sync.lock"), + } +} + +// AcquireLock tries to create an exclusive lock file +func (s *ActivityStorage) AcquireLock() error { + file, err := os.OpenFile(s.lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + if err != nil { + if os.IsExist(err) { + return fmt.Errorf("sync already in progress") + } + return fmt.Errorf("failed to acquire lock: %w", err) + } + file.Close() + return nil +} + +// ReleaseLock removes the lock file +func (s *ActivityStorage) ReleaseLock() error { + if err := os.Remove(s.lockPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to release lock: %w", err) + } + return nil +} + +func (s *ActivityStorage) Save(activity *models.Activity) error { + filename := fmt.Sprintf("%s-%s.json", + activity.Date.Format("2006-01-02"), + sanitizeFilename(activity.Name)) + targetPath := filepath.Join(s.dataDir, "activities", filename) + + data, err := json.MarshalIndent(activity, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal activity: %w", err) + } + + // Atomic write using temp file and rename + tmpFile, err := os.CreateTemp(filepath.Dir(targetPath), "tmp-*.json") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(data); err != nil { + return fmt.Errorf("failed to write activity data: %w", err) + } + + // Sync to ensure write completes before rename + if err := tmpFile.Sync(); err != nil { + return fmt.Errorf("failed to sync temp file: %w", err) + } + + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + + if err := os.Rename(tmpFile.Name(), targetPath); err != nil { + return fmt.Errorf("failed to atomically replace activity file: %w", err) + } + + return nil +} + +func (s *ActivityStorage) LoadAll() ([]*models.Activity, error) { + activitiesDir := filepath.Join(s.dataDir, "activities") + + files, err := os.ReadDir(activitiesDir) + if err != nil { + return nil, err + } + + var activities []*models.Activity + for _, file := range files { + if filepath.Ext(file.Name()) != ".json" { + continue + } + + activity, err := s.loadActivity(filepath.Join(activitiesDir, file.Name())) + if err != nil { + continue // Skip invalid files + } + activities = append(activities, activity) + } + + sort.Slice(activities, func(i, j int) bool { + return activities[i].Date.After(activities[j].Date) + }) + + return activities, nil +} + +func (s *ActivityStorage) loadActivity(path string) (*models.Activity, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var activity models.Activity + if err := json.Unmarshal(data, &activity); err != nil { + return nil, err + } + + return &activity, nil +} + +func sanitizeFilename(name string) string { + replacer := strings.NewReplacer( + "/", "-", "\\", "-", ":", "-", "*", "-", + "?", "-", "\"", "-", "<", "-", ">", "-", + "|", "-", " ", "-", + ) + return replacer.Replace(name) +} diff --git a/fitness-tui/internal/storage/analysis.go b/fitness-tui/internal/storage/analysis.go index 951997e..489a399 100644 --- a/fitness-tui/internal/storage/analysis.go +++ b/fitness-tui/internal/storage/analysis.go @@ -1,3 +1,94 @@ package storage -// Analysis caching implementation will be added here +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/sstent/fitness-tui/internal/tui/models" +) + +const ( + analysisDir = "analysis" + metaSuffix = "-meta.json" +) + +type AnalysisCache struct { + storagePath string +} + +func NewAnalysisCache(storagePath string) *AnalysisCache { + return &AnalysisCache{ + storagePath: filepath.Join(storagePath, analysisDir), + } +} + +type AnalysisMetadata struct { + ActivityID string `json:"activity_id"` + GeneratedAt time.Time `json:"generated_at"` + ModelUsed string `json:"model_used"` + Hash string `json:"hash"` +} + +func (c *AnalysisCache) StoreAnalysis(activity *models.Activity, content string, meta AnalysisMetadata) error { + basePath := filepath.Join(c.storagePath, activity.ID) + if err := os.MkdirAll(basePath, 0755); err != nil { + return fmt.Errorf("failed to create analysis dir: %w", err) + } + + // Write analysis content + contentPath := filepath.Join(basePath, "analysis.md") + if err := os.WriteFile(contentPath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write analysis: %w", err) + } + + // Write metadata + metaPath := filepath.Join(basePath, metaSuffix) + metaJSON, err := json.Marshal(meta) + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + + if err := os.WriteFile(metaPath, metaJSON, 0644); err != nil { + return fmt.Errorf("failed to write metadata: %w", err) + } + + return nil +} + +func (c *AnalysisCache) GetAnalysis(activityID string) (string, *AnalysisMetadata, error) { + basePath := filepath.Join(c.storagePath, activityID) + contentPath := filepath.Join(basePath, "analysis.md") + metaPath := filepath.Join(basePath, metaSuffix) + + content, err := os.ReadFile(contentPath) + if err != nil { + return "", nil, fmt.Errorf("failed to read analysis: %w", err) + } + + metaJSON, err := os.ReadFile(metaPath) + if err != nil { + return "", nil, fmt.Errorf("failed to read metadata: %w", err) + } + + var meta AnalysisMetadata + if err := json.Unmarshal(metaJSON, &meta); err != nil { + return "", nil, fmt.Errorf("failed to unmarshal metadata: %w", err) + } + + return string(content), &meta, nil +} + +func (c *AnalysisCache) HasFreshAnalysis(activityID string, ttl time.Duration) bool { + basePath := filepath.Join(c.storagePath, activityID) + metaPath := filepath.Join(basePath, metaSuffix) + + info, err := os.Stat(metaPath) + if err != nil { + return false + } + + return time.Since(info.ModTime()) < ttl +} diff --git a/fitness-tui/internal/tui/app.go b/fitness-tui/internal/tui/app.go index e8a4313..a86c981 100644 --- a/fitness-tui/internal/tui/app.go +++ b/fitness-tui/internal/tui/app.go @@ -26,7 +26,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (a *App) View() string { - return "AICyclingCoach-GO\n\nPress q to quit\n" + return a.currentModel.View() } func (a *App) Run() error { diff --git a/fitness-tui/internal/tui/models/activity.go b/fitness-tui/internal/tui/models/activity.go index 875a9f8..6adb251 100644 --- a/fitness-tui/internal/tui/models/activity.go +++ b/fitness-tui/internal/tui/models/activity.go @@ -6,13 +6,15 @@ import ( ) type Activity struct { - ID string - Name string - Type string - Date time.Time - Duration time.Duration - Distance float64 // meters - Metrics ActivityMetrics + ID string + Name string + Description string + Type string + Date time.Time + Duration time.Duration + Distance float64 // meters + Elevation float64 + Metrics ActivityMetrics } type ActivityMetrics struct { @@ -22,12 +24,18 @@ type ActivityMetrics struct { AvgSpeed float64 // km/h ElevationGain float64 // meters ElevationLoss float64 // meters + HeartRateData []float64 + ElevationData []float64 } func (a *Activity) FormattedDuration() string { hours := int(a.Duration.Hours()) minutes := int(a.Duration.Minutes()) % 60 - return fmt.Sprintf("%02d:%02d", hours, minutes) + seconds := int(a.Duration.Seconds()) % 60 + if hours > 0 { + return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds) + } + return fmt.Sprintf("%02d:%02d", minutes, seconds) } func (a *Activity) FormattedDistance() string { @@ -35,6 +43,9 @@ func (a *Activity) FormattedDistance() string { } func (a *Activity) FormattedPace() string { + if a.Metrics.AvgPace <= 0 { + return "--:--" + } minutes := int(a.Metrics.AvgPace) / 60 seconds := int(a.Metrics.AvgPace) % 60 return fmt.Sprintf("%d:%02d/km", minutes, seconds) diff --git a/fitness-tui/internal/tui/screens/activity_list.go b/fitness-tui/internal/tui/screens/activity_list.go index f03181f..13b86c2 100644 --- a/fitness-tui/internal/tui/screens/activity_list.go +++ b/fitness-tui/internal/tui/screens/activity_list.go @@ -13,6 +13,11 @@ import ( "github.com/sstent/fitness-tui/internal/tui/models" ) +var ( + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true).Padding(1, 2) + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Italic(true) +) + type ActivityList struct { list list.Model storage *storage.ActivityStorage @@ -109,7 +114,10 @@ func (m *ActivityList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(m.loadActivities, m.setLoading(false)) case syncErrorMsg: - m.statusMsg = fmt.Sprintf("Sync error: %v", msg.error) + m.statusMsg = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). // Red color for errors + MarginTop(1). + Render(fmt.Sprintf("⚠️ Sync failed: %v\nPress 's' to retry", msg.error)) return m, m.setLoading(false) } @@ -138,6 +146,10 @@ func (m *ActivityList) View() string { } // Messages and commands +type ActivitySelectedMsg struct { + Activity *models.Activity +} + type activitiesLoadedMsg struct { activities []*models.Activity } @@ -155,6 +167,11 @@ func (m *ActivityList) loadActivities() tea.Msg { } func (m *ActivityList) syncActivities() tea.Msg { + if err := m.storage.AcquireLock(); err != nil { + return syncErrorMsg{err} + } + defer m.storage.ReleaseLock() + activities, err := m.garminClient.GetActivities(context.Background(), 50) if err != nil { return syncErrorMsg{err}