# 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 `
Sync and manage your Garmin Connect activities
| Date | Type | Duration | Distance | Avg HR | Max HR | Calories | Status |
|---|---|---|---|---|---|---|---|
| Loading activities... | |||||||