go baby go

This commit is contained in:
2025-09-12 18:58:11 -07:00
parent d50b49fa07
commit 95086eafb5
12 changed files with 489 additions and 47 deletions

View File

@@ -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() {

View File

@@ -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

View File

@@ -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=

View File

@@ -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
}
}
}

View File

@@ -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"
)

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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}