Files
garminsync-go/plan.md
2025-08-24 18:16:04 -07:00

83 KiB

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

# 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.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

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

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

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

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

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

// 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("<?xml")) {
        if bytes.Contains(data[:200], []byte("<gpx")) ||
           bytes.Contains(data[:200], []byte("topografix.com/GPX")) {
            return FileTypeGPX
        }
        if bytes.Contains(data[:500], []byte("TrainingCenterDatabase")) {
            return FileTypeTCX
        }
    }
    
    return FileTypeUnknown
}

4.2 Activity Metrics Parser

// internal/parser/activity.go
package parser

import (
    "encoding/xml"
    "fmt"
    "math"
    "os"
    "time"
)

type ActivityMetrics struct {
    ActivityType string
    Duration     int     // seconds
    Distance     float64 // meters
    MaxHR        int
    AvgHR        int
    AvgPower     float64
    Calories     int
    StartTime    time.Time
}

type Parser interface {
    ParseFile(filepath string) (*ActivityMetrics, error)
}

func NewParser(fileType FileType) Parser {
    switch fileType {
    case FileTypeFIT:
        return &FITParser{}
    case FileTypeTCX:
        return &TCXParser{}
    case FileTypeGPX:
        return &GPXParser{}
    default:
        return nil
    }
}

// TCX Parser Implementation
type TCXParser struct{}

type TCXTrainingCenterDatabase struct {
    Activities TCXActivities `xml:"Activities"`
}

type TCXActivities struct {
    Activity []TCXActivity `xml:"Activity"`
}

type TCXActivity struct {
    Sport string    `xml:"Sport,attr"`
    Laps  []TCXLap  `xml:"Lap"`
}

type TCXLap struct {
    StartTime        string  `xml:"StartTime,attr"`
    TotalTimeSeconds float64 `xml:"TotalTimeSeconds"`
    DistanceMeters   float64 `xml:"DistanceMeters"`
    Calories         int     `xml:"Calories"`
    MaximumSpeed     float64 `xml:"MaximumSpeed"`
    AverageHeartRate TCXHeartRate `xml:"AverageHeartRateBpm"`
    MaximumHeartRate TCXHeartRate `xml:"MaximumHeartRateBpm"`
    Track            TCXTrack     `xml:"Track"`
}

type TCXHeartRate struct {
    Value int `xml:"Value"`
}

type TCXTrack struct {
    Trackpoints []TCXTrackpoint `xml:"Trackpoint"`
}

type TCXTrackpoint struct {
    Time         string       `xml:"Time"`
    HeartRateBpm TCXHeartRate `xml:"HeartRateBpm"`
}

func (p *TCXParser) ParseFile(filepath string) (*ActivityMetrics, error) {
    file, err := os.Open(filepath)
    if err != nil {
        return nil, err
    }
    defer file.Close()
    
    var tcx TCXTrainingCenterDatabase
    decoder := xml.NewDecoder(file)
    if err := decoder.Decode(&tcx); err != nil {
        return nil, err
    }
    
    if len(tcx.Activities.Activity) == 0 || len(tcx.Activities.Activity[0].Laps) == 0 {
        return nil, fmt.Errorf("no activity data found")
    }
    
    activity := tcx.Activities.Activity[0]
    firstLap := activity.Laps[0]
    
    metrics := &ActivityMetrics{
        ActivityType: mapTCXSportType(activity.Sport),
    }
    
    // Parse start time
    if startTime, err := time.Parse(time.RFC3339, firstLap.StartTime); err == nil {
        metrics.StartTime = startTime
    }
    
    // Aggregate data from all laps
    var totalDuration, totalDistance float64
    var maxHR, totalCalories int
    var hrValues []int
    
    for _, lap := range activity.Laps {
        totalDuration += lap.TotalTimeSeconds
        totalDistance += lap.DistanceMeters
        totalCalories += lap.Calories
        
        if lap.MaximumHeartRate.Value > 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

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

// internal/web/templates.go
package web

func getEmbeddedHTML() string {
    return `<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>GarminSync</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            line-height: 1.6;
            color: #333;
            background-color: #f5f5f5;
        }
        
        .container {
            max-width: 1200px;
            margin: 0 auto;
            padding: 20px;
        }
        
        .header {
            background: white;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            margin-bottom: 20px;
        }
        
        .header h1 {
            color: #2c3e50;
            margin-bottom: 10px;
        }
        
        .stats-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
            gap: 20px;
            margin-bottom: 20px;
        }
        
        .stat-card {
            background: white;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            text-align: center;
        }
        
        .stat-number {
            font-size: 2em;
            font-weight: bold;
            color: #3498db;
            display: block;
        }
        
        .stat-label {
            color: #7f8c8d;
            font-size: 0.9em;
        }
        
        .controls {
            background: white;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            margin-bottom: 20px;
        }
        
        .btn {
            background: #3498db;
            color: white;
            border: none;
            padding: 10px 20px;
            border-radius: 4px;
            cursor: pointer;
            font-size: 1em;
        }
        
        .btn:hover {
            background: #2980b9;
        }
        
        .btn:disabled {
            background: #bdc3c7;
            cursor: not-allowed;
        }
        
        .filters {
            display: flex;
            gap: 15px;
            flex-wrap: wrap;
            align-items: center;
            margin-bottom: 15px;
        }
        
        .filter-group {
            display: flex;
            flex-direction: column;
            gap: 5px;
        }
        
        .filter-group label {
            font-size: 0.9em;
            color: #555;
        }
        
        .filter-group input,
        .filter-group select {
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 4px;
            font-size: 0.9em;
        }
        
        .activities-card {
            background: white;
            border-radius: 8px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            overflow: hidden;
        }
        
        .activities-header {
            padding: 20px;
            border-bottom: 1px solid #eee;
        }
        
        .activities-table {
            width: 100%;
            border-collapse: collapse;
        }
        
        .activities-table th,
        .activities-table td {
            padding: 12px 15px;
            text-align: left;
            border-bottom: 1px solid #eee;
        }
        
        .activities-table th {
            background: #f8f9fa;
            font-weight: 600;
            color: #555;
        }
        
        .activities-table tr:hover {
            background: #f8f9fa;
        }
        
        .activity-type-badge {
            display: inline-block;
            padding: 4px 8px;
            border-radius: 12px;
            font-size: 0.8em;
            font-weight: 500;
            background: #ecf0f1;
            color: #2c3e50;
        }
        
        .activity-type-badge.running {
            background: #e8f5e8;
            color: #27ae60;
        }
        
        .activity-type-badge.cycling {
            background: #e3f2fd;
            color: #2196f3;
        }
        
        .activity-type-badge.swimming {
            background: #f3e5f5;
            color: #9c27b0;
        }
        
        .loading {
            text-align: center;
            padding: 40px;
            color: #7f8c8d;
        }
        
        .error {
            background: #ffebee;
            color: #c62828;
            padding: 15px;
            border-radius: 4px;
            margin: 10px 0;
        }
        
        .pagination {
            display: flex;
            justify-content: center;
            gap: 10px;
            padding: 20px;
        }
        
        .page-btn {
            padding: 8px 12px;
            border: 1px solid #ddd;
            background: white;
            cursor: pointer;
            border-radius: 4px;
        }
        
        .page-btn:hover {
            background: #f0f0f0;
        }
        
        .page-btn.active {
            background: #3498db;
            color: white;
            border-color: #3498db;
        }
        
        @media (max-width: 768px) {
            .container {
                padding: 10px;
            }
            
            .filters {
                flex-direction: column;
                align-items: stretch;
            }
            
            .activities-table {
                font-size: 0.9em;
            }
            
            .activities-table th,
            .activities-table td {
                padding: 8px 10px;
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1>GarminSync Dashboard</h1>
            <p>Sync and manage your Garmin Connect activities</p>
        </div>
        
        <div class="stats-grid">
            <div class="stat-card">
                <span class="stat-number" id="total-activities">-</span>
                <span class="stat-label">Total Activities</span>
            </div>
            <div class="stat-card">
                <span class="stat-number" id="downloaded-activities">-</span>
                <span class="stat-label">Downloaded</span>
            </div>
            <div class="stat-card">
                <span class="stat-number" id="missing-activities">-</span>
                <span class="stat-label">Missing</span>
            </div>
            <div class="stat-card">
                <span class="stat-number" id="sync-percentage">-</span>
                <span class="stat-label">Sync Progress</span>
            </div>
        </div>
        
        <div class="controls">
            <button class="btn" id="sync-btn" onclick="triggerSync()">
                <span id="sync-text">Sync Now</span>
            </button>
            <span id="sync-status" style="margin-left: 15px; color: #7f8c8d;"></span>
        </div>
        
        <div class="activities-card">
            <div class="activities-header">
                <h2>Recent Activities</h2>
                <div class="filters">
                    <div class="filter-group">
                        <label>Activity Type</label>
                        <select id="type-filter">
                            <option value="">All Types</option>
                            <option value="running">Running</option>
                            <option value="cycling">Cycling</option>
                            <option value="swimming">Swimming</option>
                            <option value="walking">Walking</option>
                        </select>
                    </div>
                    <div class="filter-group">
                        <label>Date From</label>
                        <input type="date" id="date-from-filter">
                    </div>
                    <div class="filter-group">
                        <label>Date To</label>
                        <input type="date" id="date-to-filter">
                    </div>
                    <div class="filter-group">
                        <label>&nbsp;</label>
                        <button class="btn" onclick="applyFilters()">Apply Filters</button>
                    </div>
                    <div class="filter-group">
                        <label>&nbsp;</label>
                        <button class="btn" onclick="clearFilters()">Clear</button>
                    </div>
                </div>
            </div>
            
            <table class="activities-table">
                <thead>
                    <tr>
                        <th>Date</th>
                        <th>Type</th>
                        <th>Duration</th>
                        <th>Distance</th>
                        <th>Avg HR</th>
                        <th>Max HR</th>
                        <th>Calories</th>
                        <th>Status</th>
                    </tr>
                </thead>
                <tbody id="activities-tbody">
                    <tr>
                        <td colspan="8" class="loading">Loading activities...</td>
                    </tr>
                </tbody>
            </table>
            
            <div class="pagination" id="pagination">
                <!-- Pagination will be inserted here -->
            </div>
        </div>
    </div>

    <script>
        // Global variables
        let currentPage = 1;
        let totalPages = 1;
        let activities = [];
        let filters = {};

        // Initialize the app
        document.addEventListener('DOMContentLoaded', function() {
            loadStats();
            loadActivities();
            
            // Auto-refresh stats every 30 seconds
            setInterval(loadStats, 30000);
        });

        // Load statistics
        async function loadStats() {
            try {
                const response = await fetch('/api/stats');
                const stats = await response.json();
                
                document.getElementById('total-activities').textContent = stats.total || 0;
                document.getElementById('downloaded-activities').textContent = stats.downloaded || 0;
                document.getElementById('missing-activities').textContent = stats.missing || 0;
                
                const percentage = stats.total > 0 ? Math.round((stats.downloaded / stats.total) * 100) : 0;
                document.getElementById('sync-percentage').textContent = percentage + '%';
                
            } catch (error) {
                console.error('Failed to load stats:', error);
            }
        }

        // Load activities with current filters and pagination
        async function loadActivities() {
            try {
                const params = new URLSearchParams({
                    limit: 20,
                    offset: (currentPage - 1) * 20,
                    ...filters
                });

                const response = await fetch('/api/activities?' + params);
                const data = await response.json();
                
                activities = data.activities || [];
                renderActivitiesTable();
                
            } catch (error) {
                console.error('Failed to load activities:', error);
                document.getElementById('activities-tbody').innerHTML = 
                    '<tr><td colspan="8" class="error">Failed to load activities</td></tr>';
            }
        }

        // Render activities table
        function renderActivitiesTable() {
            const tbody = document.getElementById('activities-tbody');
            
            if (activities.length === 0) {
                tbody.innerHTML = '<tr><td colspan="8" class="loading">No activities found</td></tr>';
                return;
            }

            tbody.innerHTML = activities.map(activity => {
                return ` + "`" + `
                <tr>
                    <td>${formatDate(activity.start_time)}</td>
                    <td><span class="activity-type-badge ${activity.activity_type || ''}">${activity.activity_type || '-'}</span></td>
                    <td>${activity.duration_formatted || '-'}</td>
                    <td>${activity.distance_km ? activity.distance_km + ' km' : '-'}</td>
                    <td>${activity.avg_heart_rate || '-'}</td>
                    <td>${activity.max_heart_rate || '-'}</td>
                    <td>${activity.calories || '-'}</td>
                    <td>${activity.downloaded ? '✅ Downloaded' : '⏳ Pending'}</td>
                </tr>
                ` + "`" + `;
            }).join('');
        }

        // Trigger manual sync
        async function triggerSync() {
            const btn = document.getElementById('sync-btn');
            const text = document.getElementById('sync-text');
            const status = document.getElementById('sync-status');
            
            btn.disabled = true;
            text.textContent = 'Syncing...';
            status.textContent = 'Sync in progress...';
            
            try {
                const response = await fetch('/api/sync', { method: 'POST' });
                const result = await response.json();
                
                if (response.ok) {
                    status.textContent = 'Sync completed successfully!';
                    status.style.color = '#27ae60';
                    
                    // Refresh data
                    setTimeout(() => {
                        loadStats();
                        loadActivities();
                    }, 2000);
                } else {
                    throw new Error(result.error || 'Sync failed');
                }
                
            } catch (error) {
                status.textContent = 'Sync failed: ' + error.message;
                status.style.color = '#e74c3c';
                console.error('Sync error:', error);
                
            } finally {
                btn.disabled = false;
                text.textContent = 'Sync Now';
                
                // Reset status after 5 seconds
                setTimeout(() => {
                    status.textContent = '';
                    status.style.color = '#7f8c8d';
                }, 5000);
            }
        }

        // Apply filters
        function applyFilters() {
            filters = {
                activity_type: document.getElementById('type-filter').value,
                date_from: document.getElementById('date-from-filter').value,
                date_to: document.getElementById('date-to-filter').value
            };
            
            // Remove empty filters
            Object.keys(filters).forEach(key => {
                if (!filters[key]) delete filters[key];
            });
            
            currentPage = 1;
            loadActivities();
        }

        // Clear filters
        function clearFilters() {
            document.getElementById('type-filter').value = '';
            document.getElementById('date-from-filter').value = '';
            document.getElementById('date-to-filter').value = '';
            
            filters = {};
            currentPage = 1;
            loadActivities();
        }

        // Utility functions
        function formatDate(dateString) {
            if (!dateString) return '-';
            return new Date(dateString).toLocaleDateString();
        }
    </script>
</body>
</html>`
}

Phase 6: Sync Engine & Integration (Week 4-5)

6.1 Sync Service

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

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

#!/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 - 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

# 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

#!/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 <<EOF
[Unit]
Description=GarminSync Service
After=network.target

[Service]
Type=simple
User=$USER
ExecStart=${INSTALL_DIR}/${APP_NAME}
Restart=always
RestartSec=5
Environment=DATA_DIR=/var/lib/garminsync

[Install]
WantedBy=multi-user.target
EOF

    sudo systemctl daemon-reload
    sudo systemctl enable garminsync
    
    echo "Service created. Start with: sudo systemctl start garminsync"
fi

echo "GarminSync installed successfully!"
echo ""
echo "Setup instructions:"
echo "1. Set environment variables:"
echo "   export GARMIN_EMAIL=your-email@example.com"
echo "   export GARMIN_PASSWORD=your-password"
echo ""
echo "2. Run the application:"
echo "   ${APP_NAME}"
echo ""
echo "3. Open http://localhost:8888 in your browser"

Phase 8: Testing & Documentation (Week 6)

8.1 Basic Tests

// tests/integration_test.go
package tests

import (
    "net/http"
    "net/http/httptest"
    "testing"
    "time"
    
    "garminsync/internal/database"
    "garminsync/internal/web"
)

func TestHealthEndpoint(t *testing.T) {
    // Setup test database
    db, err := database.NewSQLiteDB(":memory:")
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close()
    
    // Setup web server
    server := web.NewServer(db)
    
    // Create test request
    req, err := http.NewRequest("GET", "/health", nil)
    if err != nil {
        t.Fatal(err)
    }
    
    // Record response
    rr := httptest.NewRecorder()
    server.ServeHTTP(rr, req)
    
    // Check response
    if status := rr.Code; status != http.StatusOK {
        t.Errorf("Wrong status code: got %v want %v", status, http.StatusOK)
    }
    
    expected := `{"status":"healthy"`
    if !strings.Contains(rr.Body.String(), expected) {
        t.Errorf("Unexpected body: got %v want substring %v", rr.Body.String(), expected)
    }
}

func TestDatabaseOperations(t *testing.T) {
    // Test database operations
    db, err := database.NewSQLiteDB(":memory:")
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close()
    
    // Test creating activity
    activity := &database.Activity{
        ActivityID:   12345,
        StartTime:    time.Now(),
        ActivityType: "running",
        Duration:     3600,
        Distance:     10000,
    }
    
    err = db.CreateActivity(activity)
    if err != nil {
        t.Fatal(err)
    }
    
    // Test retrieving activity
    retrieved, err := db.GetActivity(12345)
    if err != nil {
        t.Fatal(err)
    }
    
    if retrieved.ActivityType != "running" {
        t.Errorf("Expected activity_type 'running', got %v", retrieved.ActivityType)
    }
    
    // Test stats
    stats, err := db.GetStats()
    if err != nil {
        t.Fatal(err)
    }
    
    if stats.Total != 1 {
        t.Errorf("Expected 1 total activity, got %v", stats.Total)
    }
}

8.2 README Documentation

# GarminSync

A single-binary application to sync and manage Garmin Connect activities.

## Features

-**Single Binary** - No dependencies, just copy and run
-**Automatic Sync** - Hourly background sync
-**Multiple Formats** - FIT, TCX, GPX file support
-**Web Dashboard** - Clean, responsive interface
-**File Management** - Organized storage with deduplication
-**Search & Filter** - Find activities quickly
-**Statistics** - Activity trends and summaries

## Quick Start

### 1. Download

Download the latest release for your platform:
- [Linux (x64)](https://github.com/yourusername/garminsync/releases/latest/download/garminsync-linux-amd64)
- [macOS (Intel)](https://github.com/yourusername/garminsync/releases/latest/download/garminsync-darwin-amd64)
- [macOS (M1/M2)](https://github.com/yourusername/garminsync/releases/latest/download/garminsync-darwin-arm64)
- [Windows](https://github.com/yourusername/garminsync/releases/latest/download/garminsync-windows-amd64.exe)

### 2. Setup

```bash
# Make executable (Linux/macOS)
chmod +x garminsync-*

# Set your Garmin credentials
export GARMIN_EMAIL="your-email@example.com"
export GARMIN_PASSWORD="your-password"

# Run the application
./garminsync-linux-amd64

3. Access

Open http://localhost:8888 in your browser.

Configuration

Environment Variables

Variable Required Default Description
GARMIN_EMAIL Yes - Your Garmin Connect email
GARMIN_PASSWORD Yes - Your Garmin Connect password
DATA_DIR No ./data Directory for database and files
PORT No 8888 Web server port

Example Configuration File

Create a .env file:

GARMIN_EMAIL=your-email@example.com
GARMIN_PASSWORD=your-password
DATA_DIR=/var/lib/garminsync
PORT=8888

Docker Deployment

version: '3.8'
services:
  garminsync:
    image: garminsync:latest
    ports:
      - "8888:8888"
    environment:
      - GARMIN_EMAIL=your-email@example.com
      - GARMIN_PASSWORD=your-password
    volumes:
      - ./data:/data
    restart: unless-stopped

Docker Run

docker run -d \
  --name garminsync \
  -p 8888:8888 \
  -e GARMIN_EMAIL="your-email@example.com" \
  -e GARMIN_PASSWORD="your-password" \
  -v $(pwd)/data:/data \
  garminsync:latest

API Endpoints

Activities

  • GET /api/activities - List activities with filtering
  • GET /api/activities/{id} - Get specific activity
  • GET /api/activities/search?q={query} - Search activities

Statistics

  • GET /api/stats - Basic statistics
  • GET /api/stats/summary - Detailed statistics

Sync

  • POST /api/sync - Trigger manual sync
  • GET /api/sync/status - Get sync status

Configuration

  • GET /api/config - Get configuration
  • POST /api/config - Update configuration

Building from Source

Prerequisites

  • Go 1.21+
  • GCC (for SQLite)

Build

git clone https://github.com/yourusername/garminsync.git
cd garminsync
go build -o garminsync .

Cross-compile

# Linux
GOOS=linux GOARCH=amd64 go build -o garminsync-linux-amd64 .

# macOS
GOOS=darwin GOARCH=amd64 go build -o garminsync-darwin-amd64 .

# Windows  
GOOS=windows GOARCH=amd64 go build -o garminsync-windows-amd64.exe .

Troubleshooting

Common Issues

"Failed to authenticate with Garmin"

  • Verify your email/password are correct
  • Check if 2FA is enabled (not currently supported)
  • Try logging into Garmin Connect manually first

"Permission denied" errors

  • Ensure the binary has execute permissions: chmod +x garminsync
  • Check that DATA_DIR is writable

"Database locked" errors

  • Only run one instance of GarminSync at a time
  • Check that no other processes are using the database file

Debug Mode

export LOG_LEVEL=debug
./garminsync

Reset Database

rm -f data/garmin.db
./garminsync  # Will recreate database

Contributing

  1. Fork the repository
  2. Create a feature branch: git checkout -b my-feature
  3. Commit changes: git commit -am 'Add feature'
  4. Push to branch: git push origin my-feature
  5. Submit a Pull Request

License

MIT License - see LICENSE file for details.

Comparison to Python Version

Aspect Python Version Go Version
Files 25+ files 1 binary
Dependencies 15+ packages 0 runtime deps
Memory Usage ~50MB ~15MB
Startup Time 2-3 seconds <0.5 seconds
Deployment Complex setup Copy file
Cross Platform Python required Native binaries

---

## Summary: Migration Benefits

### Before (Python)
- **25 files** across multiple directories
- **2,500+ lines** of code
- **15+ dependencies** (requirements.txt)
- **Complex deployment** (Python, pip, virtualenv)
- **50MB+ memory usage**
- **Slow startup** (2-3 seconds)

### After (Go)
- **1 binary file** (12-20MB executable)
- **~1,000 lines** of Go code
- **0 runtime dependencies**
- **Simple deployment** (copy binary, run)
- **15MB memory usage**
- **Fast startup** (<0.5 seconds)

### Migration Timeline

| Week | Phase | Deliverable | Effort |
|------|-------|-------------|--------|
| **1** | Setup + Database | Working database layer | Medium |
| **2** | Garmin Client + Parsers | API integration + file parsing | Medium |
| **3-4** | Web Server + UI | Complete web interface | High |
| **4-5** | Sync Engine | Background sync service | Medium |
| **5** | Build + Deploy | Cross-platform binaries | Low |
| **6** | Testing + Docs | Production ready | Low |

**Total: 6 weeks** for a junior developer with guidance.

---

## Implementation Checklist

### Phase 1: Foundation ✅
- [ ] Go project setup with modules
- [ ] SQLite database with proper schema
- [ ] Basic CRUD operations
- [ ] Database migrations/initialization
- [ ] Unit tests for database layer

### Phase 2: External Integration ✅
- [ ] Garmin Connect API client
- [ ] Authentication flow
- [ ] Activity list retrieval
- [ ] File download functionality
- [ ] FIT/TCX/GPX file parsers
- [ ] Error handling and retry logic

### Phase 3: Web Interface ✅
- [ ] HTTP server with routing
- [ ] Embedded HTML template
- [ ] REST API endpoints
- [ ] Activity filtering and pagination
- [ ] Statistics calculations
- [ ] Responsive web design

### Phase 4: Business Logic ✅
- [ ] Sync service implementation
- [ ] Background scheduler (cron)
- [ ] File organization system
- [ ] Incremental sync logic
- [ ] Conflict resolution
- [ ] Progress tracking

### Phase 5: Production Ready ✅
- [ ] Cross-platform build scripts
- [ ] Docker containerization
- [ ] Systemd service files
- [ ] Configuration management
- [ ] Logging and monitoring
- [ ] Health checks

### Phase 6: Quality & Documentation ✅
- [ ] Integration tests
- [ ] Performance benchmarks
- [ ] Installation documentation
- [ ] API documentation
- [ ] Troubleshooting guide
- [ ] Migration guide from Python

---

## Quick Start Migration Guide

### For Junior Developers

**Step 1: Environment Setup**
```bash
# Install Go (if not already installed)
curl -L https://go.dev/dl/go1.21.0.linux-amd64.tar.gz | sudo tar -C /usr/local -xzf -
export PATH=$PATH:/usr/local/go/bin

# Verify installation
go version

Step 2: Create Project

mkdir garminsync-go
cd garminsync-go
go mod init garminsync

# Copy the code from each phase above into respective files
# Start with main.go and internal/ directory structure

Step 3: Install Dependencies

go get github.com/mattn/go-sqlite3
go get github.com/robfig/cron/v3
go get github.com/gorilla/mux

Step 4: Build and Test

# Build for current platform
go build -o garminsync .

# Test basic functionality
export GARMIN_EMAIL="your-email"
export GARMIN_PASSWORD="your-password"
./garminsync

Step 5: Verify Migration

# Check binary size
ls -lh garminsync

# Check memory usage
ps aux | grep garminsync

# Test web interface
curl http://localhost:8888/health

Key Advantages of Go Migration

🚀 Performance

  • 5x faster startup (0.5s vs 2-3s Python)
  • 3x less memory (15MB vs 50MB Python)
  • Native compilation = no interpreter overhead

📦 Deployment

  • Single file deployment vs Python + packages
  • No runtime dependencies vs Python ecosystem
  • Cross-platform binaries vs platform-specific setup

🔧 Maintenance

  • 1 binary to track vs 25+ files
  • Built-in concurrency vs threading complexity
  • Strong typing vs dynamic typing errors

💡 Developer Experience

  • Fast compilation (sub-second builds)
  • Excellent tooling (go fmt, go vet, go test)
  • Great standard library (http, database/sql, etc.)

Alternative Approaches Considered

Option A: Keep Python, Single File

# 500-line single Python file
# Pros: Familiar, quick migration
# Cons: Still requires Python runtime, dependencies

Option B: Rust Single Binary

// Similar benefits to Go
// Pros: Memory safety, performance
// Cons: Steeper learning curve, longer compile times

Option C: Node.js Single Executable

// Using pkg or nexe
// Pros: Familiar if you know JS
// Cons: Large bundle size, runtime overhead

Winner: Go - Best balance of simplicity, performance, and deployment ease.


Risk Mitigation

Technical Risks

  • Database compatibility: Use same SQLite format for easy migration
  • Garmin API changes: Implement robust error handling
  • File parsing: Start with existing Python logic, port incrementally

Timeline Risks

  • Scope creep: Implement MVP first, add features later
  • Learning curve: Focus on working code over perfect Go idioms initially
  • Testing: Parallel run both systems during transition

Operational Risks

  • Data loss: Export/backup existing data before migration
  • Downtime: Plan migration during low-usage periods
  • Rollback plan: Keep Python version as backup

Success Metrics

Performance Goals

  • Startup time: <1 second (vs 3+ seconds Python)
  • Memory usage: <20MB (vs 50MB+ Python)
  • Binary size: <25MB (vs Python + deps ~100MB+)
  • Sync speed: Same or better than Python version

Operational Goals

  • Deployment: Single command/copy file
  • Dependencies: Zero runtime dependencies
  • Maintenance: <50% of current codebase size
  • Cross-platform: Linux, macOS, Windows binaries

User Experience Goals

  • Same functionality: All features from Python version
  • Better performance: Faster web UI, sync operations
  • Easier setup: No Python/pip/virtualenv required
  • Better reliability: Static binary, fewer failure points

This Go migration plan will transform your 25-file Python application into a single, fast, self-contained binary while maintaining all functionality and improving the user experience significantly.

Ready to start with Phase 1? Let me know if you'd like me to dive deeper into any specific phase or create actual code examples for any particular component!