mirror of
https://github.com/sstent/aicyclingcoach-go.git
synced 2026-01-25 16:41:48 +00:00
go baby go
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user