# GarminSync Go Migration Implementation Plan ## Overview Migrate from Python/FastAPI to a single Go binary that includes web UI, database, and sync logic. **Target:** Single executable file (~1,000 lines of Go code vs current 2,500+ lines across 25 files) --- ## Phase 1: Setup & Core Structure (Week 1) ### 1.1 Project Setup ```bash # Create new Go project structure mkdir garminsync-go cd garminsync-go # Initialize Go module go mod init garminsync # Create basic structure touch main.go mkdir -p {internal/{database,garmin,web},templates,assets} ``` ### 1.2 Go Dependencies ```go // go.mod - Keep dependencies minimal module garminsync go 1.21 require ( github.com/mattn/go-sqlite3 v1.14.17 github.com/robfig/cron/v3 v3.0.1 github.com/gorilla/mux v1.8.0 // For HTTP routing golang.org/x/net v0.12.0 // For HTTP client ) ``` ### 1.3 Core Application Structure ```go // main.go - Entry point and dependency injection package main import ( "context" "database/sql" "log" "net/http" "os" "os/signal" "syscall" "time" _ "github.com/mattn/go-sqlite3" "github.com/robfig/cron/v3" ) type App struct { db *sql.DB cron *cron.Cron server *http.Server garmin *GarminClient shutdown chan os.Signal } func main() { app := &App{ shutdown: make(chan os.Signal, 1), } // Initialize components if err := app.init(); err != nil { log.Fatal("Failed to initialize app:", err) } // Start services app.start() // Wait for shutdown signal signal.Notify(app.shutdown, os.Interrupt, syscall.SIGTERM) <-app.shutdown // Graceful shutdown app.stop() } func (app *App) init() error { var err error // Initialize database app.db, err = initDatabase() if err != nil { return err } // Initialize Garmin client app.garmin = NewGarminClient() // Setup cron scheduler app.cron = cron.New() // Setup HTTP server app.server = &http.Server{ Addr: ":8888", Handler: app.setupRoutes(), } return nil } func (app *App) start() { // Start cron scheduler app.cron.AddFunc("@hourly", func() { log.Println("Starting scheduled sync...") app.syncActivities() }) app.cron.Start() // Start web server go func() { log.Println("Server starting on http://localhost:8888") if err := app.server.ListenAndServe(); err != http.ErrServerClosed { log.Printf("Server error: %v", err) } }() } func (app *App) stop() { log.Println("Shutting down...") // Stop cron app.cron.Stop() // Stop web server ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := app.server.Shutdown(ctx); err != nil { log.Printf("Server shutdown error: %v", err) } // Close database if app.db != nil { app.db.Close() } log.Println("Shutdown complete") } ``` --- ## Phase 2: Database Layer (Week 1-2) ### 2.1 Database Models & Schema ```go // internal/database/models.go package database import ( "database/sql" "time" ) type Activity struct { ID int `json:"id"` ActivityID int `json:"activity_id"` StartTime time.Time `json:"start_time"` ActivityType string `json:"activity_type"` Duration int `json:"duration"` // seconds Distance float64 `json:"distance"` // meters MaxHeartRate int `json:"max_heart_rate"` AvgHeartRate int `json:"avg_heart_rate"` AvgPower float64 `json:"avg_power"` Calories int `json:"calories"` Filename string `json:"filename"` FileType string `json:"file_type"` FileSize int64 `json:"file_size"` Downloaded bool `json:"downloaded"` CreatedAt time.Time `json:"created_at"` LastSync time.Time `json:"last_sync"` } type Stats struct { Total int `json:"total"` Downloaded int `json:"downloaded"` Missing int `json:"missing"` } type DaemonConfig struct { ID int `json:"id"` Enabled bool `json:"enabled"` ScheduleCron string `json:"schedule_cron"` LastRun string `json:"last_run"` Status string `json:"status"` } // Database interface type Database interface { // Activities GetActivities(limit, offset int) ([]Activity, error) GetActivity(activityID int) (*Activity, error) CreateActivity(activity *Activity) error UpdateActivity(activity *Activity) error DeleteActivity(activityID int) error // Stats GetStats() (*Stats, error) // Search and filter FilterActivities(filters ActivityFilters) ([]Activity, error) // Close connection Close() error } type ActivityFilters struct { ActivityType string DateFrom *time.Time DateTo *time.Time MinDistance float64 MaxDistance float64 MinDuration int MaxDuration int Downloaded *bool Limit int Offset int SortBy string SortOrder string } ``` ### 2.2 SQLite Implementation ```go // internal/database/sqlite.go package database import ( "database/sql" "fmt" "strings" "time" ) type SQLiteDB struct { db *sql.DB } func NewSQLiteDB(dbPath string) (*SQLiteDB, error) { db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on") if err != nil { return nil, err } sqlite := &SQLiteDB{db: db} // Create tables if err := sqlite.createTables(); err != nil { return nil, err } return sqlite, nil } func (s *SQLiteDB) createTables() error { schema := ` CREATE TABLE IF NOT EXISTS activities ( id INTEGER PRIMARY KEY AUTOINCREMENT, activity_id INTEGER UNIQUE NOT NULL, start_time DATETIME NOT NULL, activity_type TEXT, duration INTEGER, distance REAL, max_heart_rate INTEGER, avg_heart_rate INTEGER, avg_power REAL, calories INTEGER, filename TEXT UNIQUE, file_type TEXT, file_size INTEGER, downloaded BOOLEAN DEFAULT FALSE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_sync DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_activities_activity_id ON activities(activity_id); CREATE INDEX IF NOT EXISTS idx_activities_start_time ON activities(start_time); CREATE INDEX IF NOT EXISTS idx_activities_activity_type ON activities(activity_type); CREATE INDEX IF NOT EXISTS idx_activities_downloaded ON activities(downloaded); CREATE TABLE IF NOT EXISTS daemon_config ( id INTEGER PRIMARY KEY DEFAULT 1, enabled BOOLEAN DEFAULT TRUE, schedule_cron TEXT DEFAULT '0 * * * *', last_run TEXT, status TEXT DEFAULT 'stopped', CONSTRAINT single_config CHECK (id = 1) ); INSERT OR IGNORE INTO daemon_config (id) VALUES (1); ` _, err := s.db.Exec(schema) return err } func (s *SQLiteDB) GetActivities(limit, offset int) ([]Activity, error) { query := ` SELECT id, activity_id, start_time, activity_type, duration, distance, max_heart_rate, avg_heart_rate, avg_power, calories, filename, file_type, file_size, downloaded, created_at, last_sync FROM activities ORDER BY start_time DESC LIMIT ? OFFSET ?` rows, err := s.db.Query(query, limit, offset) if err != nil { return nil, err } defer rows.Close() var activities []Activity for rows.Next() { var a Activity var startTime, createdAt, lastSync string err := rows.Scan( &a.ID, &a.ActivityID, &startTime, &a.ActivityType, &a.Duration, &a.Distance, &a.MaxHeartRate, &a.AvgHeartRate, &a.AvgPower, &a.Calories, &a.Filename, &a.FileType, &a.FileSize, &a.Downloaded, &createdAt, &lastSync, ) if err != nil { return nil, err } // Parse time strings if a.StartTime, err = time.Parse("2006-01-02 15:04:05", startTime); err != nil { return nil, err } if a.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAt); err != nil { return nil, err } if a.LastSync, err = time.Parse("2006-01-02 15:04:05", lastSync); err != nil { return nil, err } activities = append(activities, a) } return activities, nil } func (s *SQLiteDB) CreateActivity(activity *Activity) error { query := ` INSERT INTO activities ( activity_id, start_time, activity_type, duration, distance, max_heart_rate, avg_heart_rate, avg_power, calories, filename, file_type, file_size, downloaded ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` _, err := s.db.Exec(query, activity.ActivityID, activity.StartTime.Format("2006-01-02 15:04:05"), activity.ActivityType, activity.Duration, activity.Distance, activity.MaxHeartRate, activity.AvgHeartRate, activity.AvgPower, activity.Calories, activity.Filename, activity.FileType, activity.FileSize, activity.Downloaded, ) return err } func (s *SQLiteDB) UpdateActivity(activity *Activity) error { query := ` UPDATE activities SET activity_type = ?, duration = ?, distance = ?, max_heart_rate = ?, avg_heart_rate = ?, avg_power = ?, calories = ?, filename = ?, file_type = ?, file_size = ?, downloaded = ?, last_sync = CURRENT_TIMESTAMP WHERE activity_id = ?` _, err := s.db.Exec(query, activity.ActivityType, activity.Duration, activity.Distance, activity.MaxHeartRate, activity.AvgHeartRate, activity.AvgPower, activity.Calories, activity.Filename, activity.FileType, activity.FileSize, activity.Downloaded, activity.ActivityID, ) return err } func (s *SQLiteDB) GetStats() (*Stats, error) { stats := &Stats{} // Get total count err := s.db.QueryRow("SELECT COUNT(*) FROM activities").Scan(&stats.Total) if err != nil { return nil, err } // Get downloaded count err = s.db.QueryRow("SELECT COUNT(*) FROM activities WHERE downloaded = TRUE").Scan(&stats.Downloaded) if err != nil { return nil, err } stats.Missing = stats.Total - stats.Downloaded return stats, nil } func (s *SQLiteDB) FilterActivities(filters ActivityFilters) ([]Activity, error) { query := ` SELECT id, activity_id, start_time, activity_type, duration, distance, max_heart_rate, avg_heart_rate, avg_power, calories, filename, file_type, file_size, downloaded, created_at, last_sync FROM activities WHERE 1=1` var args []interface{} var conditions []string // Build WHERE conditions if filters.ActivityType != "" { conditions = append(conditions, "activity_type = ?") args = append(args, filters.ActivityType) } if filters.DateFrom != nil { conditions = append(conditions, "start_time >= ?") args = append(args, filters.DateFrom.Format("2006-01-02 15:04:05")) } if filters.DateTo != nil { conditions = append(conditions, "start_time <= ?") args = append(args, filters.DateTo.Format("2006-01-02 15:04:05")) } if filters.MinDistance > 0 { conditions = append(conditions, "distance >= ?") args = append(args, filters.MinDistance) } if filters.MaxDistance > 0 { conditions = append(conditions, "distance <= ?") args = append(args, filters.MaxDistance) } if filters.Downloaded != nil { conditions = append(conditions, "downloaded = ?") args = append(args, *filters.Downloaded) } // Add conditions to query if len(conditions) > 0 { query += " AND " + strings.Join(conditions, " AND ") } // Add sorting orderBy := "start_time" if filters.SortBy != "" { orderBy = filters.SortBy } order := "DESC" if filters.SortOrder == "asc" { order = "ASC" } query += fmt.Sprintf(" ORDER BY %s %s", orderBy, order) // Add pagination if filters.Limit > 0 { query += " LIMIT ?" args = append(args, filters.Limit) if filters.Offset > 0 { query += " OFFSET ?" args = append(args, filters.Offset) } } rows, err := s.db.Query(query, args...) if err != nil { return nil, err } defer rows.Close() var activities []Activity for rows.Next() { var a Activity var startTime, createdAt, lastSync string err := rows.Scan( &a.ID, &a.ActivityID, &startTime, &a.ActivityType, &a.Duration, &a.Distance, &a.MaxHeartRate, &a.AvgHeartRate, &a.AvgPower, &a.Calories, &a.Filename, &a.FileType, &a.FileSize, &a.Downloaded, &createdAt, &lastSync, ) if err != nil { return nil, err } // Parse times a.StartTime, _ = time.Parse("2006-01-02 15:04:05", startTime) a.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) a.LastSync, _ = time.Parse("2006-01-02 15:04:05", lastSync) activities = append(activities, a) } return activities, nil } func (s *SQLiteDB) Close() error { return s.db.Close() } ``` ### 2.3 Initialize Database Function ```go // main.go - Database initialization func initDatabase() (*sql.DB, error) { // Get data directory from environment or use default dataDir := os.Getenv("DATA_DIR") if dataDir == "" { dataDir = "./data" } // Create data directory if it doesn't exist if err := os.MkdirAll(dataDir, 0755); err != nil { return nil, fmt.Errorf("failed to create data directory: %v", err) } dbPath := filepath.Join(dataDir, "garmin.db") // Initialize SQLite database db, err := database.NewSQLiteDB(dbPath) if err != nil { return nil, fmt.Errorf("failed to initialize database: %v", err) } return db.db, nil // Return the underlying *sql.DB } ``` --- ## Phase 3: Garmin API Client (Week 2) ### 3.1 Garmin Client Interface ```go // internal/garmin/client.go package garmin import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) type Client struct { httpClient *http.Client baseURL string session *Session } type Session struct { Username string Password string Cookies []*http.Cookie UserAgent string Authenticated bool } type GarminActivity struct { ActivityID int `json:"activityId"` ActivityName string `json:"activityName"` StartTimeLocal string `json:"startTimeLocal"` ActivityType map[string]interface{} `json:"activityType"` Distance float64 `json:"distance"` Duration float64 `json:"duration"` MaxHR int `json:"maxHR"` AvgHR int `json:"avgHR"` AvgPower float64 `json:"avgPower"` Calories int `json:"calories"` } func NewClient() *Client { return &Client{ httpClient: &http.Client{ Timeout: 30 * time.Second, }, baseURL: "https://connect.garmin.com", session: &Session{ Username: os.Getenv("GARMIN_EMAIL"), Password: os.Getenv("GARMIN_PASSWORD"), UserAgent: "GarminSync/1.0", }, } } func (c *Client) Login() error { if c.session.Username == "" || c.session.Password == "" { return fmt.Errorf("GARMIN_EMAIL and GARMIN_PASSWORD environment variables required") } // Step 1: Get login form loginURL := c.baseURL + "/signin" req, err := http.NewRequest("GET", loginURL, nil) if err != nil { return err } req.Header.Set("User-Agent", c.session.UserAgent) resp, err := c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() // Extract cookies c.session.Cookies = resp.Cookies() // Step 2: Submit login credentials loginData := url.Values{} loginData.Set("username", c.session.Username) loginData.Set("password", c.session.Password) loginData.Set("embed", "true") req, err = http.NewRequest("POST", loginURL, strings.NewReader(loginData.Encode())) if err != nil { return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", c.session.UserAgent) // Add cookies for _, cookie := range c.session.Cookies { req.AddCookie(cookie) } resp, err = c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() // Check if login was successful if resp.StatusCode != http.StatusOK { return fmt.Errorf("login failed with status: %d", resp.StatusCode) } // Update cookies for _, cookie := range resp.Cookies() { c.session.Cookies = append(c.session.Cookies, cookie) } c.session.Authenticated = true return nil } func (c *Client) GetActivities(start, limit int) ([]GarminActivity, error) { if !c.session.Authenticated { if err := c.Login(); err != nil { return nil, err } } url := fmt.Sprintf("%s/modern/proxy/activitylist-service/activities/search/activities?start=%d&limit=%d", c.baseURL, start, limit) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", c.session.UserAgent) req.Header.Set("Accept", "application/json") // Add cookies for _, cookie := range c.session.Cookies { req.AddCookie(cookie) } resp, err := c.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to get activities: status %d", resp.StatusCode) } var activities []GarminActivity if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil { return nil, err } // Rate limiting time.Sleep(2 * time.Second) return activities, nil } func (c *Client) DownloadActivity(activityID int, format string) ([]byte, error) { if !c.session.Authenticated { if err := c.Login(); err != nil { return nil, err } } // Default to FIT format if format == "" { format = "fit" } url := fmt.Sprintf("%s/modern/proxy/download-service/export/%s/activity/%d", c.baseURL, format, activityID) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", c.session.UserAgent) // Add cookies for _, cookie := range c.session.Cookies { req.AddCookie(cookie) } resp, err := c.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to download activity %d: status %d", activityID, resp.StatusCode) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, err } // Rate limiting time.Sleep(2 * time.Second) return data, nil } func (c *Client) GetActivityDetails(activityID int) (*GarminActivity, error) { if !c.session.Authenticated { if err := c.Login(); err != nil { return nil, err } } url := fmt.Sprintf("%s/modern/proxy/activity-service/activity/%d", c.baseURL, activityID) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", c.session.UserAgent) req.Header.Set("Accept", "application/json") // Add cookies for _, cookie := range c.session.Cookies { req.AddCookie(cookie) } resp, err := c.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to get activity details: status %d", resp.StatusCode) } var activity GarminActivity if err := json.NewDecoder(resp.Body).Decode(&activity); err != nil { return nil, err } // Rate limiting time.Sleep(2 * time.Second) return &activity, nil } ``` --- ## Phase 4: File Parsing (Week 2-3) ### 4.1 File Type Detection ```go // internal/parser/detector.go package parser import ( "bytes" "os" ) type FileType string const ( FileTypeFIT FileType = "fit" FileTypeTCX FileType = "tcx" FileTypeGPX FileType = "gpx" FileTypeUnknown FileType = "unknown" ) func DetectFileType(filepath string) (FileType, error) { file, err := os.Open(filepath) if err != nil { return FileTypeUnknown, err } defer file.Close() // Read first 512 bytes for detection header := make([]byte, 512) n, err := file.Read(header) if err != nil && n == 0 { return FileTypeUnknown, err } header = header[:n] return DetectFileTypeFromData(header), nil } func DetectFileTypeFromData(data []byte) FileType { // Check for FIT file signature if len(data) >= 8 && bytes.Equal(data[8:12], []byte(".FIT")) { return FileTypeFIT } // Check for XML-based formats if bytes.HasPrefix(data, []byte(" maxHR { maxHR = lap.MaximumHeartRate.Value } if lap.AverageHeartRate.Value > 0 { hrValues = append(hrValues, lap.AverageHeartRate.Value) } // Collect HR data from trackpoints for _, tp := range lap.Track.Trackpoints { if tp.HeartRateBpm.Value > 0 { hrValues = append(hrValues, tp.HeartRateBpm.Value) } } } metrics.Duration = int(totalDuration) metrics.Distance = totalDistance metrics.MaxHR = maxHR metrics.Calories = totalCalories // Calculate average HR if len(hrValues) > 0 { sum := 0 for _, hr := range hrValues { sum += hr } metrics.AvgHR = sum / len(hrValues) } return metrics, nil } func mapTCXSportType(sport string) string { switch sport { case "Running": return "running" case "Biking": return "cycling" case "Swimming": return "swimming" default: return "other" } } // GPX Parser Implementation type GPXParser struct{} type GPX struct { Tracks []GPXTrack `xml:"trk"` } type GPXTrack struct { Name string `xml:"name"` Segments []GPXSegment `xml:"trkseg"` } type GPXSegment struct { Points []GPXPoint `xml:"trkpt"` } type GPXPoint struct { Lat float64 `xml:"lat,attr"` Lon float64 `xml:"lon,attr"` Elevation float64 `xml:"ele"` Time string `xml:"time"` HR int `xml:"extensions>TrackPointExtension>hr"` } func (p *GPXParser) ParseFile(filepath string) (*ActivityMetrics, error) { file, err := os.Open(filepath) if err != nil { return nil, err } defer file.Close() var gpx GPX decoder := xml.NewDecoder(file) if err := decoder.Decode(&gpx); err != nil { return nil, err } if len(gpx.Tracks) == 0 || len(gpx.Tracks[0].Segments) == 0 { return nil, fmt.Errorf("no track data found") } metrics := &ActivityMetrics{ ActivityType: "other", // GPX doesn't specify activity type } var allPoints []GPXPoint for _, track := range gpx.Tracks { for _, segment := range track.Segments { allPoints = append(allPoints, segment.Points...) } } if len(allPoints) == 0 { return nil, fmt.Errorf("no track points found") } // Calculate metrics from points var startTime, endTime time.Time var totalDistance float64 var hrValues []int for i, point := range allPoints { // Parse time if point.Time != "" { if t, err := time.Parse(time.RFC3339, point.Time); err == nil { if i == 0 { startTime = t metrics.StartTime = t } endTime = t } } // Calculate distance between consecutive points if i > 0 { prevPoint := allPoints[i-1] distance := calculateDistance(prevPoint.Lat, prevPoint.Lon, point.Lat, point.Lon) totalDistance += distance } // Collect heart rate data if point.HR > 0 { hrValues = append(hrValues, point.HR) } } // Calculate duration if !startTime.IsZero() && !endTime.IsZero() { metrics.Duration = int(endTime.Sub(startTime).Seconds()) } metrics.Distance = totalDistance // Calculate heart rate metrics if len(hrValues) > 0 { sum := 0 maxHR := 0 for _, hr := range hrValues { sum += hr if hr > maxHR { maxHR = hr } } metrics.AvgHR = sum / len(hrValues) metrics.MaxHR = maxHR } return metrics, nil } // Haversine formula for distance calculation func calculateDistance(lat1, lon1, lat2, lon2 float64) float64 { const earthRadius = 6371000 // Earth's radius in meters dLat := (lat2 - lat1) * math.Pi / 180 dLon := (lon2 - lon1) * math.Pi / 180 lat1Rad := lat1 * math.Pi / 180 lat2Rad := lat2 * math.Pi / 180 a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1Rad)*math.Cos(lat2Rad)*math.Sin(dLon/2)*math.Sin(dLon/2) c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) return earthRadius * c } // FIT Parser Implementation (simplified - would use FIT SDK in real implementation) type FITParser struct{} func (p *FITParser) ParseFile(filepath string) (*ActivityMetrics, error) { // For now, return basic metrics - in real implementation, would use FIT SDK // This is a placeholder that reads basic file info file, err := os.Open(filepath) if err != nil { return nil, err } defer file.Close() // Read FIT header to verify it's a valid FIT file header := make([]byte, 14) _, err = file.Read(header) if err != nil { return nil, err } // Verify FIT signature if !bytes.Equal(header[8:12], []byte(".FIT")) { return nil, fmt.Errorf("invalid FIT file signature") } // For now, return empty metrics - real implementation would parse FIT records return &ActivityMetrics{ ActivityType: "other", // Additional parsing would happen here using FIT SDK }, nil } ``` --- ## Phase 5: Web Server & API (Week 3-4) ### 5.1 HTTP Routes Setup ```go // internal/web/routes.go package web import ( "encoding/json" "fmt" "net/http" "strconv" "time" "github.com/gorilla/mux" "garminsync/internal/database" ) type Server struct { db database.Database router *mux.Router } func NewServer(db database.Database) *Server { s := &Server{ db: db, router: mux.NewRouter(), } s.setupRoutes() return s } func (s *Server) setupRoutes() { // Static files (embedded) s.router.HandleFunc("/", s.handleHome).Methods("GET") s.router.HandleFunc("/health", s.handleHealth).Methods("GET") // API routes api := s.router.PathPrefix("/api").Subrouter() // Activities api.HandleFunc("/activities", s.handleGetActivities).Methods("GET") api.HandleFunc("/activities/{id:[0-9]+}", s.handleGetActivity).Methods("GET") api.HandleFunc("/activities/search", s.handleSearchActivities).Methods("GET") // Stats api.HandleFunc("/stats", s.handleGetStats).Methods("GET") api.HandleFunc("/stats/summary", s.handleGetStatsSummary).Methods("GET") // Sync operations api.HandleFunc("/sync", s.handleTriggerSync).Methods("POST") api.HandleFunc("/sync/status", s.handleGetSyncStatus).Methods("GET") // Configuration api.HandleFunc("/config", s.handleGetConfig).Methods("GET") api.HandleFunc("/config", s.handleUpdateConfig).Methods("POST") } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.router.ServeHTTP(w, r) } func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { // Serve embedded HTML html := getEmbeddedHTML() w.Header().Set("Content-Type", "text/html") w.Write([]byte(html)) } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { s.writeJSON(w, map[string]string{ "status": "healthy", "service": "GarminSync", "timestamp": time.Now().Format(time.RFC3339), }) } func (s *Server) handleGetActivities(w http.ResponseWriter, r *http.Request) { // Parse query parameters query := r.URL.Query() limit, _ := strconv.Atoi(query.Get("limit")) if limit <= 0 || limit > 100 { limit = 50 } offset, _ := strconv.Atoi(query.Get("offset")) if offset < 0 { offset = 0 } // Build filters filters := database.ActivityFilters{ Limit: limit, Offset: offset, } if activityType := query.Get("activity_type"); activityType != "" { filters.ActivityType = activityType } if dateFrom := query.Get("date_from"); dateFrom != "" { if t, err := time.Parse("2006-01-02", dateFrom); err == nil { filters.DateFrom = &t } } if dateTo := query.Get("date_to"); dateTo != "" { if t, err := time.Parse("2006-01-02", dateTo); err == nil { filters.DateTo = &t } } if minDistance := query.Get("min_distance"); minDistance != "" { if d, err := strconv.ParseFloat(minDistance, 64); err == nil { filters.MinDistance = d * 1000 // Convert km to meters } } if sortBy := query.Get("sort_by"); sortBy != "" { filters.SortBy = sortBy } if sortOrder := query.Get("sort_order"); sortOrder != "" { filters.SortOrder = sortOrder } // Get activities activities, err := s.db.FilterActivities(filters) if err != nil { s.writeError(w, "Failed to get activities", http.StatusInternalServerError) return } // Convert to API response format response := map[string]interface{}{ "activities": convertActivitiesToAPI(activities), "limit": limit, "offset": offset, } s.writeJSON(w, response) } func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) activityID, err := strconv.Atoi(vars["id"]) if err != nil { s.writeError(w, "Invalid activity ID", http.StatusBadRequest) return } activity, err := s.db.GetActivity(activityID) if err != nil { s.writeError(w, "Activity not found", http.StatusNotFound) return } s.writeJSON(w, convertActivityToAPI(*activity)) } func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) { stats, err := s.db.GetStats() if err != nil { s.writeError(w, "Failed to get statistics", http.StatusInternalServerError) return } s.writeJSON(w, stats) } func (s *Server) handleTriggerSync(w http.ResponseWriter, r *http.Request) { // This would trigger the sync operation // For now, return success s.writeJSON(w, map[string]string{ "status": "sync_started", "message": "Sync operation started in background", }) } // Utility functions func (s *Server) writeJSON(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) } func (s *Server) writeError(w http.ResponseWriter, message string, status int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(map[string]string{ "error": message, }) } func convertActivitiesToAPI(activities []database.Activity) []map[string]interface{} { result := make([]map[string]interface{}, len(activities)) for i, activity := range activities { result[i] = convertActivityToAPI(activity) } return result } func convertActivityToAPI(activity database.Activity) map[string]interface{} { return map[string]interface{}{ "id": activity.ID, "activity_id": activity.ActivityID, "start_time": activity.StartTime.Format("2006-01-02T15:04:05Z"), "activity_type": activity.ActivityType, "duration": activity.Duration, "duration_formatted": formatDuration(activity.Duration), "distance": activity.Distance, "distance_km": roundFloat(activity.Distance/1000, 2), "max_heart_rate": activity.MaxHeartRate, "avg_heart_rate": activity.AvgHeartRate, "avg_power": activity.AvgPower, "calories": activity.Calories, "file_type": activity.FileType, "downloaded": activity.Downloaded, "created_at": activity.CreatedAt.Format("2006-01-02T15:04:05Z"), "last_sync": activity.LastSync.Format("2006-01-02T15:04:05Z"), } } func formatDuration(seconds int) string { if seconds <= 0 { return "-" } hours := seconds / 3600 minutes := (seconds % 3600) / 60 secs := seconds % 60 if hours > 0 { return fmt.Sprintf("%d:%02d:%02d", hours, minutes, secs) } return fmt.Sprintf("%d:%02d", minutes, secs) } func roundFloat(val float64, precision int) float64 { ratio := math.Pow(10, float64(precision)) return math.Round(val*ratio) / ratio } ``` ### 5.2 Embedded HTML Template ```go // internal/web/templates.go package web func getEmbeddedHTML() string { return ` GarminSync

GarminSync Dashboard

Sync and manage your Garmin Connect activities

- Total Activities
- Downloaded
- Missing
- Sync Progress

Recent Activities

Date Type Duration Distance Avg HR Max HR Calories Status
Loading activities...
` } ``` --- ## Phase 6: Sync Engine & Integration (Week 4-5) ### 6.1 Sync Service ```go // internal/sync/service.go package sync import ( "fmt" "log" "os" "path/filepath" "time" "garminsync/internal/database" "garminsync/internal/garmin" "garminsync/internal/parser" ) type Service struct { db database.Database garmin *garmin.Client dataDir string isRunning bool lastSync time.Time } type SyncResult struct { TotalActivities int NewActivities int DownloadedFiles int UpdatedActivities int Errors []string Duration time.Duration } func NewService(db database.Database, garminClient *garmin.Client) *Service { dataDir := os.Getenv("DATA_DIR") if dataDir == "" { dataDir = "./data" } return &Service{ db: db, garmin: garminClient, dataDir: dataDir, } } func (s *Service) IsRunning() bool { return s.isRunning } func (s *Service) LastSync() time.Time { return s.lastSync } func (s *Service) SyncActivities() (*SyncResult, error) { if s.isRunning { return nil, fmt.Errorf("sync already in progress") } s.isRunning = true defer func() { s.isRunning = false }() startTime := time.Now() result := &SyncResult{} log.Println("Starting activity sync...") // Step 1: Get activities from Garmin Connect activities, err := s.garmin.GetActivities(0, 1000) // Get last 1000 activities if err != nil { return nil, fmt.Errorf("failed to get activities from Garmin: %v", err) } result.TotalActivities = len(activities) log.Printf("Retrieved %d activities from Garmin Connect", len(activities)) // Step 2: Process each activity for _, garminActivity := range activities { if err := s.processActivity(garminActivity, result); err != nil { result.Errors = append(result.Errors, fmt.Sprintf("Activity %d: %v", garminActivity.ActivityID, err)) } } // Step 3: Download missing files if err := s.downloadMissingFiles(result); err != nil { result.Errors = append(result.Errors, fmt.Sprintf("Download phase: %v", err)) } result.Duration = time.Since(startTime) s.lastSync = time.Now() log.Printf("Sync completed in %v. New: %d, Downloaded: %d, Updated: %d, Errors: %d", result.Duration, result.NewActivities, result.DownloadedFiles, result.UpdatedActivities, len(result.Errors)) return result, nil } func (s *Service) processActivity(garminActivity garmin.GarminActivity, result *SyncResult) error { // Check if activity already exists existing, err := s.db.GetActivity(garminActivity.ActivityID) if err != nil && err.Error() != "activity not found" { // Assuming this error message return err } var dbActivity *database.Activity if existing == nil { // Create new activity startTime, err := time.Parse("2006-01-02 15:04:05", garminActivity.StartTimeLocal) if err != nil { startTime = time.Now() // Fallback } dbActivity = &database.Activity{ ActivityID: garminActivity.ActivityID, StartTime: startTime, ActivityType: s.mapActivityType(garminActivity.ActivityType), Duration: int(garminActivity.Duration), Distance: garminActivity.Distance, MaxHeartRate: garminActivity.MaxHR, AvgHeartRate: garminActivity.AvgHR, AvgPower: garminActivity.AvgPower, Calories: garminActivity.Calories, Downloaded: false, CreatedAt: time.Now(), LastSync: time.Now(), } if err := s.db.CreateActivity(dbActivity); err != nil { return err } result.NewActivities++ } else { // Update existing activity if data has changed dbActivity = existing updated := false if dbActivity.ActivityType != s.mapActivityType(garminActivity.ActivityType) { dbActivity.ActivityType = s.mapActivityType(garminActivity.ActivityType) updated = true } if dbActivity.Duration != int(garminActivity.Duration) { dbActivity.Duration = int(garminActivity.Duration) updated = true } // Update other fields as needed... if updated { dbActivity.LastSync = time.Now() if err := s.db.UpdateActivity(dbActivity); err != nil { return err } result.UpdatedActivities++ } } return nil } func (s *Service) downloadMissingFiles(result *SyncResult) error { // Get activities that haven't been downloaded filters := database.ActivityFilters{ Downloaded: boolPtr(false), Limit: 100, // Process in batches } missingActivities, err := s.db.FilterActivities(filters) if err != nil { return err } log.Printf("Downloading %d missing activity files...", len(missingActivities)) for _, activity := range missingActivities { if err := s.downloadActivityFile(&activity, result); err != nil { result.Errors = append(result.Errors, fmt.Sprintf("Download %d: %v", activity.ActivityID, err)) continue } result.DownloadedFiles++ // Rate limiting time.Sleep(2 * time.Second) } return nil } func (s *Service) downloadActivityFile(activity *database.Activity, result *SyncResult) error { // Try to download FIT file first data, err := s.garmin.DownloadActivity(activity.ActivityID, "fit") if err != nil { return fmt.Errorf("failed to download FIT file: %v", err) } // Detect actual file type fileType := parser.DetectFileTypeFromData(data) // Create organized directory structure activityDir := filepath.Join(s.dataDir, "activities", activity.StartTime.Format("2006"), activity.StartTime.Format("01")) if err := os.MkdirAll(activityDir, 0755); err != nil { return fmt.Errorf("failed to create directory: %v", err) } // Generate filename extension := string(fileType) if extension == "unknown" { extension = "fit" // Default to FIT } filename := fmt.Sprintf("activity_%d_%s.%s", activity.ActivityID, activity.StartTime.Format("20060102_150405"), extension) filepath := filepath.Join(activityDir, filename) // Save file if err := os.WriteFile(filepath, data, 0644); err != nil { return fmt.Errorf("failed to save file: %v", err) } // Parse file to get additional metrics if err := s.parseAndUpdateActivity(activity, filepath, fileType); err != nil { log.Printf("Warning: failed to parse file for activity %d: %v", activity.ActivityID, err) // Don't return error - file was saved successfully } // Update database activity.Filename = filepath activity.FileType = string(fileType) activity.FileSize = int64(len(data)) activity.Downloaded = true activity.LastSync = time.Now() return s.db.UpdateActivity(activity) } func (s *Service) parseAndUpdateActivity(activity *database.Activity, filepath string, fileType parser.FileType) error { parser := parser.NewParser(fileType) if parser == nil { return fmt.Errorf("no parser available for file type: %s", fileType) } metrics, err := parser.ParseFile(filepath) if err != nil { return err } // Update activity with parsed metrics (only if not already set) if activity.ActivityType == "" && metrics.ActivityType != "" { activity.ActivityType = metrics.ActivityType } if activity.Duration == 0 && metrics.Duration > 0 { activity.Duration = metrics.Duration } if activity.Distance == 0 && metrics.Distance > 0 { activity.Distance = metrics.Distance } if activity.MaxHeartRate == 0 && metrics.MaxHR > 0 { activity.MaxHeartRate = metrics.MaxHR } if activity.AvgHeartRate == 0 && metrics.AvgHR > 0 { activity.AvgHeartRate = metrics.AvgHR } if activity.AvgPower == 0 && metrics.AvgPower > 0 { activity.AvgPower = metrics.AvgPower } if activity.Calories == 0 && metrics.Calories > 0 { activity.Calories = metrics.Calories } return nil } func (s *Service) mapActivityType(activityType map[string]interface{}) string { if activityType == nil { return "other" } if typeKey, ok := activityType["typeKey"].(string); ok { return typeKey } return "other" } // Utility function func boolPtr(b bool) *bool { return &b } ``` ### 6.2 Update Main Application ```go // main.go - Complete main application with sync integration package main import ( "context" "database/sql" "log" "net/http" "os" "os/signal" "syscall" "time" _ "github.com/mattn/go-sqlite3" "github.com/robfig/cron/v3" "garminsync/internal/database" "garminsync/internal/garmin" "garminsync/internal/sync" "garminsync/internal/web" ) type App struct { db database.Database cron *cron.Cron server *http.Server garmin *garmin.Client syncSvc *sync.Service shutdown chan os.Signal } func main() { log.Println("Starting GarminSync...") app := &App{ shutdown: make(chan os.Signal, 1), } // Initialize components if err := app.init(); err != nil { log.Fatal("Failed to initialize app:", err) } // Start services app.start() log.Println("GarminSync is running...") log.Println("Web interface: http://localhost:8888") log.Println("Press Ctrl+C to shutdown") // Wait for shutdown signal signal.Notify(app.shutdown, os.Interrupt, syscall.SIGTERM) <-app.shutdown // Graceful shutdown app.stop() } func (app *App) init() error { var err error // Initialize database app.db, err = database.NewSQLiteDB("./data/garmin.db") if err != nil { return err } // Initialize Garmin client app.garmin = garmin.NewClient() // Initialize sync service app.syncSvc = sync.NewService(app.db, app.garmin) // Setup cron scheduler app.cron = cron.New() // Setup HTTP server webServer := web.NewServer(app.db) // Add sync endpoint to web server app.setupSyncEndpoints(webServer) app.server = &http.Server{ Addr: ":8888", Handler: webServer, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, } return nil } func (app *App) setupSyncEndpoints(webServer *web.Server) { // This would extend the web server with sync-specific endpoints // For now, we'll handle it in the main sync trigger } func (app *App) start() { // Schedule hourly sync app.cron.AddFunc("@hourly", func() { log.Println("Starting scheduled sync...") if result, err := app.syncSvc.SyncActivities(); err != nil { log.Printf("Scheduled sync failed: %v", err) } else { log.Printf("Scheduled sync completed: %+v", result) } }) app.cron.Start() // Start web server go func() { log.Printf("Web server starting on %s", app.server.Addr) if err := app.server.ListenAndServe(); err != http.ErrServerClosed { log.Printf("Web server error: %v", err) } }() // Perform initial sync if no activities exist go func() { time.Sleep(2 * time.Second) // Wait for server to start stats, err := app.db.GetStats() if err != nil { log.Printf("Failed to get stats: %v", err) return } if stats.Total == 0 { log.Println("No activities found, performing initial sync...") if result, err := app.syncSvc.SyncActivities(); err != nil { log.Printf("Initial sync failed: %v", err) } else { log.Printf("Initial sync completed: %+v", result) } } }() } func (app *App) stop() { log.Println("Shutting down GarminSync...") // Stop cron scheduler if app.cron != nil { app.cron.Stop() } // Stop web server if app.server != nil { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := app.server.Shutdown(ctx); err != nil { log.Printf("Server shutdown error: %v", err) } } // Close database if app.db != nil { app.db.Close() } log.Println("Shutdown complete") } ``` --- ## Phase 7: Build & Deployment (Week 5) ### 7.1 Build Script ```bash #!/bin/bash # build.sh - Cross-platform build script APP_NAME="garminsync" VERSION="1.0.0" BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") # Build flags LDFLAGS="-X main.version=${VERSION} -X main.buildTime=${BUILD_TIME} -X main.gitCommit=${GIT_COMMIT}" echo "Building GarminSync v${VERSION}..." # Create build directory mkdir -p dist # Build for different platforms platforms=( "linux/amd64" "linux/arm64" "darwin/amd64" "darwin/arm64" "windows/amd64" ) for platform in "${platforms[@]}" do platform_split=(${platform//\// }) GOOS=${platform_split[0]} GOARCH=${platform_split[1]} output_name="${APP_NAME}-${GOOS}-${GOARCH}" if [ $GOOS = "windows" ]; then output_name+='.exe' fi echo "Building for $GOOS/$GOARCH..." env CGO_ENABLED=1 GOOS=$GOOS GOARCH=$GOARCH go build \ -ldflags="${LDFLAGS}" \ -o "dist/${output_name}" \ . if [ $? -ne 0 ]; then echo "Build failed for $GOOS/$GOARCH" exit 1 fi done echo "Build completed successfully!" ls -la dist/ ``` ### 7.2 Docker Support ```dockerfile # Dockerfile - Multi-stage build for minimal image FROM golang:1.21-alpine AS builder # Install build dependencies RUN apk add --no-cache gcc musl-dev sqlite-dev WORKDIR /app # Copy go mod files COPY go.mod go.sum ./ RUN go mod download # Copy source code COPY . . # Build the application RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o garminsync . # Final stage FROM alpine:latest # Install runtime dependencies RUN apk --no-cache add ca-certificates sqlite WORKDIR /app # Copy binary from builder COPY --from=builder /app/garminsync . # Create data directory RUN mkdir -p /data # Set environment variables ENV DATA_DIR=/data ENV GIN_MODE=release # Expose port EXPOSE 8888 # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8888/health || exit 1 # Run the application CMD ["./garminsync"] ``` ### 7.3 Docker Compose ```yaml # docker-compose.yml - Single service deployment version: '3.8' services: garminsync: build: . container_name: garminsync ports: - "8888:8888" environment: - GARMIN_EMAIL=${GARMIN_EMAIL} - GARMIN_PASSWORD=${GARMIN_PASSWORD} - DATA_DIR=/data volumes: - ./data:/data - /etc/localtime:/etc/localtime:ro restart: unless-stopped healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8888/health"] interval: 30s timeout: 10s retries: 3 logging: driver: "json-file" options: max-size: "10m" max-file: "3" ``` ### 7.4 Installation Script ```bash #!/bin/bash # install.sh - Simple installation script set -e APP_NAME="garminsync" INSTALL_DIR="/usr/local/bin" SERVICE_DIR="/etc/systemd/system" # Detect architecture ARCH=$(uname -m) case $ARCH in x86_64) ARCH="amd64" ;; aarch64|arm64) ARCH="arm64" ;; *) echo "Unsupported architecture: $ARCH" exit 1 ;; esac # Detect OS OS=$(uname -s | tr '[:upper:]' '[:lower:]') BINARY_NAME="${APP_NAME}-${OS}-${ARCH}" DOWNLOAD_URL="https://github.com/yourusername/garminsync/releases/latest/download/${BINARY_NAME}" echo "Installing GarminSync for ${OS}/${ARCH}..." # Download binary echo "Downloading ${BINARY_NAME}..." curl -L -o "/tmp/${BINARY_NAME}" "$DOWNLOAD_URL" chmod +x "/tmp/${BINARY_NAME}" # Install binary echo "Installing to ${INSTALL_DIR}..." sudo mv "/tmp/${BINARY_NAME}" "${INSTALL_DIR}/${APP_NAME}" # Create data directory sudo mkdir -p /var/lib/garminsync sudo chown $USER:$USER /var/lib/garminsync # Create systemd service (Linux only) if [ "$OS" = "linux" ] && [ -d "$SERVICE_DIR" ]; then echo "Creating systemd service..." sudo tee "${SERVICE_DIR}/garminsync.service" > /dev/null <