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> </label>
<button class="btn" onclick="applyFilters()">Apply Filters</button>
</div>
<div class="filter-group">
<label> </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
Docker Compose (Recommended)
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 filteringGET /api/activities/{id}- Get specific activityGET /api/activities/search?q={query}- Search activities
Statistics
GET /api/stats- Basic statisticsGET /api/stats/summary- Detailed statistics
Sync
POST /api/sync- Trigger manual syncGET /api/sync/status- Get sync status
Configuration
GET /api/config- Get configurationPOST /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
- Fork the repository
- Create a feature branch:
git checkout -b my-feature - Commit changes:
git commit -am 'Add feature' - Push to branch:
git push origin my-feature - 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!