mirror of
https://github.com/sstent/garminsync-go.git
synced 2025-12-06 08:01:52 +00:00
3180 lines
83 KiB
Markdown
3180 lines
83 KiB
Markdown
# GarminSync Go Migration Implementation Plan
|
|
|
|
## Overview
|
|
Migrate from Python/FastAPI to a single Go binary that includes web UI, database, and sync logic.
|
|
|
|
**Target:** Single executable file (~1,000 lines of Go code vs current 2,500+ lines across 25 files)
|
|
|
|
---
|
|
|
|
## Phase 1: Setup & Core Structure (Week 1)
|
|
|
|
### 1.1 Project Setup
|
|
```bash
|
|
# Create new Go project structure
|
|
mkdir garminsync-go
|
|
cd garminsync-go
|
|
|
|
# Initialize Go module
|
|
go mod init garminsync
|
|
|
|
# Create basic structure
|
|
touch main.go
|
|
mkdir -p {internal/{database,garmin,web},templates,assets}
|
|
```
|
|
|
|
### 1.2 Go Dependencies
|
|
```go
|
|
// go.mod - Keep dependencies minimal
|
|
module garminsync
|
|
|
|
go 1.21
|
|
|
|
require (
|
|
github.com/mattn/go-sqlite3 v1.14.17
|
|
github.com/robfig/cron/v3 v3.0.1
|
|
github.com/gorilla/mux v1.8.0 // For HTTP routing
|
|
golang.org/x/net v0.12.0 // For HTTP client
|
|
)
|
|
```
|
|
|
|
### 1.3 Core Application Structure
|
|
```go
|
|
// main.go - Entry point and dependency injection
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"github.com/robfig/cron/v3"
|
|
)
|
|
|
|
type App struct {
|
|
db *sql.DB
|
|
cron *cron.Cron
|
|
server *http.Server
|
|
garmin *GarminClient
|
|
shutdown chan os.Signal
|
|
}
|
|
|
|
func main() {
|
|
app := &App{
|
|
shutdown: make(chan os.Signal, 1),
|
|
}
|
|
|
|
// Initialize components
|
|
if err := app.init(); err != nil {
|
|
log.Fatal("Failed to initialize app:", err)
|
|
}
|
|
|
|
// Start services
|
|
app.start()
|
|
|
|
// Wait for shutdown signal
|
|
signal.Notify(app.shutdown, os.Interrupt, syscall.SIGTERM)
|
|
<-app.shutdown
|
|
|
|
// Graceful shutdown
|
|
app.stop()
|
|
}
|
|
|
|
func (app *App) init() error {
|
|
var err error
|
|
|
|
// Initialize database
|
|
app.db, err = initDatabase()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Initialize Garmin client
|
|
app.garmin = NewGarminClient()
|
|
|
|
// Setup cron scheduler
|
|
app.cron = cron.New()
|
|
|
|
// Setup HTTP server
|
|
app.server = &http.Server{
|
|
Addr: ":8888",
|
|
Handler: app.setupRoutes(),
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (app *App) start() {
|
|
// Start cron scheduler
|
|
app.cron.AddFunc("@hourly", func() {
|
|
log.Println("Starting scheduled sync...")
|
|
app.syncActivities()
|
|
})
|
|
app.cron.Start()
|
|
|
|
// Start web server
|
|
go func() {
|
|
log.Println("Server starting on http://localhost:8888")
|
|
if err := app.server.ListenAndServe(); err != http.ErrServerClosed {
|
|
log.Printf("Server error: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (app *App) stop() {
|
|
log.Println("Shutting down...")
|
|
|
|
// Stop cron
|
|
app.cron.Stop()
|
|
|
|
// Stop web server
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := app.server.Shutdown(ctx); err != nil {
|
|
log.Printf("Server shutdown error: %v", err)
|
|
}
|
|
|
|
// Close database
|
|
if app.db != nil {
|
|
app.db.Close()
|
|
}
|
|
|
|
log.Println("Shutdown complete")
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2: Database Layer (Week 1-2)
|
|
|
|
### 2.1 Database Models & Schema
|
|
```go
|
|
// internal/database/models.go
|
|
package database
|
|
|
|
import (
|
|
"database/sql"
|
|
"time"
|
|
)
|
|
|
|
type Activity struct {
|
|
ID int `json:"id"`
|
|
ActivityID int `json:"activity_id"`
|
|
StartTime time.Time `json:"start_time"`
|
|
ActivityType string `json:"activity_type"`
|
|
Duration int `json:"duration"` // seconds
|
|
Distance float64 `json:"distance"` // meters
|
|
MaxHeartRate int `json:"max_heart_rate"`
|
|
AvgHeartRate int `json:"avg_heart_rate"`
|
|
AvgPower float64 `json:"avg_power"`
|
|
Calories int `json:"calories"`
|
|
Filename string `json:"filename"`
|
|
FileType string `json:"file_type"`
|
|
FileSize int64 `json:"file_size"`
|
|
Downloaded bool `json:"downloaded"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastSync time.Time `json:"last_sync"`
|
|
}
|
|
|
|
type Stats struct {
|
|
Total int `json:"total"`
|
|
Downloaded int `json:"downloaded"`
|
|
Missing int `json:"missing"`
|
|
}
|
|
|
|
type DaemonConfig struct {
|
|
ID int `json:"id"`
|
|
Enabled bool `json:"enabled"`
|
|
ScheduleCron string `json:"schedule_cron"`
|
|
LastRun string `json:"last_run"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// Database interface
|
|
type Database interface {
|
|
// Activities
|
|
GetActivities(limit, offset int) ([]Activity, error)
|
|
GetActivity(activityID int) (*Activity, error)
|
|
CreateActivity(activity *Activity) error
|
|
UpdateActivity(activity *Activity) error
|
|
DeleteActivity(activityID int) error
|
|
|
|
// Stats
|
|
GetStats() (*Stats, error)
|
|
|
|
// Search and filter
|
|
FilterActivities(filters ActivityFilters) ([]Activity, error)
|
|
|
|
// Close connection
|
|
Close() error
|
|
}
|
|
|
|
type ActivityFilters struct {
|
|
ActivityType string
|
|
DateFrom *time.Time
|
|
DateTo *time.Time
|
|
MinDistance float64
|
|
MaxDistance float64
|
|
MinDuration int
|
|
MaxDuration int
|
|
Downloaded *bool
|
|
Limit int
|
|
Offset int
|
|
SortBy string
|
|
SortOrder string
|
|
}
|
|
```
|
|
|
|
### 2.2 SQLite Implementation
|
|
```go
|
|
// internal/database/sqlite.go
|
|
package database
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type SQLiteDB struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func NewSQLiteDB(dbPath string) (*SQLiteDB, error) {
|
|
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sqlite := &SQLiteDB{db: db}
|
|
|
|
// Create tables
|
|
if err := sqlite.createTables(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return sqlite, nil
|
|
}
|
|
|
|
func (s *SQLiteDB) createTables() error {
|
|
schema := `
|
|
CREATE TABLE IF NOT EXISTS activities (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
activity_id INTEGER UNIQUE NOT NULL,
|
|
start_time DATETIME NOT NULL,
|
|
activity_type TEXT,
|
|
duration INTEGER,
|
|
distance REAL,
|
|
max_heart_rate INTEGER,
|
|
avg_heart_rate INTEGER,
|
|
avg_power REAL,
|
|
calories INTEGER,
|
|
filename TEXT UNIQUE,
|
|
file_type TEXT,
|
|
file_size INTEGER,
|
|
downloaded BOOLEAN DEFAULT FALSE,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
last_sync DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_activities_activity_id ON activities(activity_id);
|
|
CREATE INDEX IF NOT EXISTS idx_activities_start_time ON activities(start_time);
|
|
CREATE INDEX IF NOT EXISTS idx_activities_activity_type ON activities(activity_type);
|
|
CREATE INDEX IF NOT EXISTS idx_activities_downloaded ON activities(downloaded);
|
|
|
|
CREATE TABLE IF NOT EXISTS daemon_config (
|
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
|
enabled BOOLEAN DEFAULT TRUE,
|
|
schedule_cron TEXT DEFAULT '0 * * * *',
|
|
last_run TEXT,
|
|
status TEXT DEFAULT 'stopped',
|
|
CONSTRAINT single_config CHECK (id = 1)
|
|
);
|
|
|
|
INSERT OR IGNORE INTO daemon_config (id) VALUES (1);
|
|
`
|
|
|
|
_, err := s.db.Exec(schema)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteDB) GetActivities(limit, offset int) ([]Activity, error) {
|
|
query := `
|
|
SELECT id, activity_id, start_time, activity_type, duration, distance,
|
|
max_heart_rate, avg_heart_rate, avg_power, calories, filename,
|
|
file_type, file_size, downloaded, created_at, last_sync
|
|
FROM activities
|
|
ORDER BY start_time DESC
|
|
LIMIT ? OFFSET ?`
|
|
|
|
rows, err := s.db.Query(query, limit, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var activities []Activity
|
|
for rows.Next() {
|
|
var a Activity
|
|
var startTime, createdAt, lastSync string
|
|
|
|
err := rows.Scan(
|
|
&a.ID, &a.ActivityID, &startTime, &a.ActivityType,
|
|
&a.Duration, &a.Distance, &a.MaxHeartRate, &a.AvgHeartRate,
|
|
&a.AvgPower, &a.Calories, &a.Filename, &a.FileType,
|
|
&a.FileSize, &a.Downloaded, &createdAt, &lastSync,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Parse time strings
|
|
if a.StartTime, err = time.Parse("2006-01-02 15:04:05", startTime); err != nil {
|
|
return nil, err
|
|
}
|
|
if a.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if a.LastSync, err = time.Parse("2006-01-02 15:04:05", lastSync); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
activities = append(activities, a)
|
|
}
|
|
|
|
return activities, nil
|
|
}
|
|
|
|
func (s *SQLiteDB) CreateActivity(activity *Activity) error {
|
|
query := `
|
|
INSERT INTO activities (
|
|
activity_id, start_time, activity_type, duration, distance,
|
|
max_heart_rate, avg_heart_rate, avg_power, calories,
|
|
filename, file_type, file_size, downloaded
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
|
|
_, err := s.db.Exec(query,
|
|
activity.ActivityID, activity.StartTime.Format("2006-01-02 15:04:05"),
|
|
activity.ActivityType, activity.Duration, activity.Distance,
|
|
activity.MaxHeartRate, activity.AvgHeartRate, activity.AvgPower,
|
|
activity.Calories, activity.Filename, activity.FileType,
|
|
activity.FileSize, activity.Downloaded,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteDB) UpdateActivity(activity *Activity) error {
|
|
query := `
|
|
UPDATE activities SET
|
|
activity_type = ?, duration = ?, distance = ?,
|
|
max_heart_rate = ?, avg_heart_rate = ?, avg_power = ?,
|
|
calories = ?, filename = ?, file_type = ?, file_size = ?,
|
|
downloaded = ?, last_sync = CURRENT_TIMESTAMP
|
|
WHERE activity_id = ?`
|
|
|
|
_, err := s.db.Exec(query,
|
|
activity.ActivityType, activity.Duration, activity.Distance,
|
|
activity.MaxHeartRate, activity.AvgHeartRate, activity.AvgPower,
|
|
activity.Calories, activity.Filename, activity.FileType,
|
|
activity.FileSize, activity.Downloaded, activity.ActivityID,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteDB) GetStats() (*Stats, error) {
|
|
stats := &Stats{}
|
|
|
|
// Get total count
|
|
err := s.db.QueryRow("SELECT COUNT(*) FROM activities").Scan(&stats.Total)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get downloaded count
|
|
err = s.db.QueryRow("SELECT COUNT(*) FROM activities WHERE downloaded = TRUE").Scan(&stats.Downloaded)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
stats.Missing = stats.Total - stats.Downloaded
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
func (s *SQLiteDB) FilterActivities(filters ActivityFilters) ([]Activity, error) {
|
|
query := `
|
|
SELECT id, activity_id, start_time, activity_type, duration, distance,
|
|
max_heart_rate, avg_heart_rate, avg_power, calories, filename,
|
|
file_type, file_size, downloaded, created_at, last_sync
|
|
FROM activities WHERE 1=1`
|
|
|
|
var args []interface{}
|
|
var conditions []string
|
|
|
|
// Build WHERE conditions
|
|
if filters.ActivityType != "" {
|
|
conditions = append(conditions, "activity_type = ?")
|
|
args = append(args, filters.ActivityType)
|
|
}
|
|
|
|
if filters.DateFrom != nil {
|
|
conditions = append(conditions, "start_time >= ?")
|
|
args = append(args, filters.DateFrom.Format("2006-01-02 15:04:05"))
|
|
}
|
|
|
|
if filters.DateTo != nil {
|
|
conditions = append(conditions, "start_time <= ?")
|
|
args = append(args, filters.DateTo.Format("2006-01-02 15:04:05"))
|
|
}
|
|
|
|
if filters.MinDistance > 0 {
|
|
conditions = append(conditions, "distance >= ?")
|
|
args = append(args, filters.MinDistance)
|
|
}
|
|
|
|
if filters.MaxDistance > 0 {
|
|
conditions = append(conditions, "distance <= ?")
|
|
args = append(args, filters.MaxDistance)
|
|
}
|
|
|
|
if filters.Downloaded != nil {
|
|
conditions = append(conditions, "downloaded = ?")
|
|
args = append(args, *filters.Downloaded)
|
|
}
|
|
|
|
// Add conditions to query
|
|
if len(conditions) > 0 {
|
|
query += " AND " + strings.Join(conditions, " AND ")
|
|
}
|
|
|
|
// Add sorting
|
|
orderBy := "start_time"
|
|
if filters.SortBy != "" {
|
|
orderBy = filters.SortBy
|
|
}
|
|
|
|
order := "DESC"
|
|
if filters.SortOrder == "asc" {
|
|
order = "ASC"
|
|
}
|
|
|
|
query += fmt.Sprintf(" ORDER BY %s %s", orderBy, order)
|
|
|
|
// Add pagination
|
|
if filters.Limit > 0 {
|
|
query += " LIMIT ?"
|
|
args = append(args, filters.Limit)
|
|
|
|
if filters.Offset > 0 {
|
|
query += " OFFSET ?"
|
|
args = append(args, filters.Offset)
|
|
}
|
|
}
|
|
|
|
rows, err := s.db.Query(query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var activities []Activity
|
|
for rows.Next() {
|
|
var a Activity
|
|
var startTime, createdAt, lastSync string
|
|
|
|
err := rows.Scan(
|
|
&a.ID, &a.ActivityID, &startTime, &a.ActivityType,
|
|
&a.Duration, &a.Distance, &a.MaxHeartRate, &a.AvgHeartRate,
|
|
&a.AvgPower, &a.Calories, &a.Filename, &a.FileType,
|
|
&a.FileSize, &a.Downloaded, &createdAt, &lastSync,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Parse times
|
|
a.StartTime, _ = time.Parse("2006-01-02 15:04:05", startTime)
|
|
a.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
|
a.LastSync, _ = time.Parse("2006-01-02 15:04:05", lastSync)
|
|
|
|
activities = append(activities, a)
|
|
}
|
|
|
|
return activities, nil
|
|
}
|
|
|
|
func (s *SQLiteDB) Close() error {
|
|
return s.db.Close()
|
|
}
|
|
```
|
|
|
|
### 2.3 Initialize Database Function
|
|
```go
|
|
// main.go - Database initialization
|
|
func initDatabase() (*sql.DB, error) {
|
|
// Get data directory from environment or use default
|
|
dataDir := os.Getenv("DATA_DIR")
|
|
if dataDir == "" {
|
|
dataDir = "./data"
|
|
}
|
|
|
|
// Create data directory if it doesn't exist
|
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
|
return nil, fmt.Errorf("failed to create data directory: %v", err)
|
|
}
|
|
|
|
dbPath := filepath.Join(dataDir, "garmin.db")
|
|
|
|
// Initialize SQLite database
|
|
db, err := database.NewSQLiteDB(dbPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize database: %v", err)
|
|
}
|
|
|
|
return db.db, nil // Return the underlying *sql.DB
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 3: Garmin API Client (Week 2)
|
|
|
|
### 3.1 Garmin Client Interface
|
|
```go
|
|
// internal/garmin/client.go
|
|
package garmin
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Client struct {
|
|
httpClient *http.Client
|
|
baseURL string
|
|
session *Session
|
|
}
|
|
|
|
type Session struct {
|
|
Username string
|
|
Password string
|
|
Cookies []*http.Cookie
|
|
UserAgent string
|
|
Authenticated bool
|
|
}
|
|
|
|
type GarminActivity struct {
|
|
ActivityID int `json:"activityId"`
|
|
ActivityName string `json:"activityName"`
|
|
StartTimeLocal string `json:"startTimeLocal"`
|
|
ActivityType map[string]interface{} `json:"activityType"`
|
|
Distance float64 `json:"distance"`
|
|
Duration float64 `json:"duration"`
|
|
MaxHR int `json:"maxHR"`
|
|
AvgHR int `json:"avgHR"`
|
|
AvgPower float64 `json:"avgPower"`
|
|
Calories int `json:"calories"`
|
|
}
|
|
|
|
func NewClient() *Client {
|
|
return &Client{
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
baseURL: "https://connect.garmin.com",
|
|
session: &Session{
|
|
Username: os.Getenv("GARMIN_EMAIL"),
|
|
Password: os.Getenv("GARMIN_PASSWORD"),
|
|
UserAgent: "GarminSync/1.0",
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *Client) Login() error {
|
|
if c.session.Username == "" || c.session.Password == "" {
|
|
return fmt.Errorf("GARMIN_EMAIL and GARMIN_PASSWORD environment variables required")
|
|
}
|
|
|
|
// Step 1: Get login form
|
|
loginURL := c.baseURL + "/signin"
|
|
req, err := http.NewRequest("GET", loginURL, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req.Header.Set("User-Agent", c.session.UserAgent)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Extract cookies
|
|
c.session.Cookies = resp.Cookies()
|
|
|
|
// Step 2: Submit login credentials
|
|
loginData := url.Values{}
|
|
loginData.Set("username", c.session.Username)
|
|
loginData.Set("password", c.session.Password)
|
|
loginData.Set("embed", "true")
|
|
|
|
req, err = http.NewRequest("POST", loginURL, strings.NewReader(loginData.Encode()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("User-Agent", c.session.UserAgent)
|
|
|
|
// Add cookies
|
|
for _, cookie := range c.session.Cookies {
|
|
req.AddCookie(cookie)
|
|
}
|
|
|
|
resp, err = c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Check if login was successful
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("login failed with status: %d", resp.StatusCode)
|
|
}
|
|
|
|
// Update cookies
|
|
for _, cookie := range resp.Cookies() {
|
|
c.session.Cookies = append(c.session.Cookies, cookie)
|
|
}
|
|
|
|
c.session.Authenticated = true
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) GetActivities(start, limit int) ([]GarminActivity, error) {
|
|
if !c.session.Authenticated {
|
|
if err := c.Login(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/modern/proxy/activitylist-service/activities/search/activities?start=%d&limit=%d",
|
|
c.baseURL, start, limit)
|
|
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("User-Agent", c.session.UserAgent)
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
// Add cookies
|
|
for _, cookie := range c.session.Cookies {
|
|
req.AddCookie(cookie)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("failed to get activities: status %d", resp.StatusCode)
|
|
}
|
|
|
|
var activities []GarminActivity
|
|
if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Rate limiting
|
|
time.Sleep(2 * time.Second)
|
|
|
|
return activities, nil
|
|
}
|
|
|
|
func (c *Client) DownloadActivity(activityID int, format string) ([]byte, error) {
|
|
if !c.session.Authenticated {
|
|
if err := c.Login(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Default to FIT format
|
|
if format == "" {
|
|
format = "fit"
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/modern/proxy/download-service/export/%s/activity/%d",
|
|
c.baseURL, format, activityID)
|
|
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("User-Agent", c.session.UserAgent)
|
|
|
|
// Add cookies
|
|
for _, cookie := range c.session.Cookies {
|
|
req.AddCookie(cookie)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("failed to download activity %d: status %d", activityID, resp.StatusCode)
|
|
}
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Rate limiting
|
|
time.Sleep(2 * time.Second)
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func (c *Client) GetActivityDetails(activityID int) (*GarminActivity, error) {
|
|
if !c.session.Authenticated {
|
|
if err := c.Login(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/modern/proxy/activity-service/activity/%d",
|
|
c.baseURL, activityID)
|
|
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("User-Agent", c.session.UserAgent)
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
// Add cookies
|
|
for _, cookie := range c.session.Cookies {
|
|
req.AddCookie(cookie)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("failed to get activity details: status %d", resp.StatusCode)
|
|
}
|
|
|
|
var activity GarminActivity
|
|
if err := json.NewDecoder(resp.Body).Decode(&activity); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Rate limiting
|
|
time.Sleep(2 * time.Second)
|
|
|
|
return &activity, nil
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 4: File Parsing (Week 2-3)
|
|
|
|
### 4.1 File Type Detection
|
|
```go
|
|
// internal/parser/detector.go
|
|
package parser
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
)
|
|
|
|
type FileType string
|
|
|
|
const (
|
|
FileTypeFIT FileType = "fit"
|
|
FileTypeTCX FileType = "tcx"
|
|
FileTypeGPX FileType = "gpx"
|
|
FileTypeUnknown FileType = "unknown"
|
|
)
|
|
|
|
func DetectFileType(filepath string) (FileType, error) {
|
|
file, err := os.Open(filepath)
|
|
if err != nil {
|
|
return FileTypeUnknown, err
|
|
}
|
|
defer file.Close()
|
|
|
|
// Read first 512 bytes for detection
|
|
header := make([]byte, 512)
|
|
n, err := file.Read(header)
|
|
if err != nil && n == 0 {
|
|
return FileTypeUnknown, err
|
|
}
|
|
|
|
header = header[:n]
|
|
|
|
return DetectFileTypeFromData(header), nil
|
|
}
|
|
|
|
func DetectFileTypeFromData(data []byte) FileType {
|
|
// Check for FIT file signature
|
|
if len(data) >= 8 && bytes.Equal(data[8:12], []byte(".FIT")) {
|
|
return FileTypeFIT
|
|
}
|
|
|
|
// Check for XML-based formats
|
|
if bytes.HasPrefix(data, []byte("<?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
|
|
```go
|
|
// 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
|
|
```go
|
|
// internal/web/routes.go
|
|
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
"garminsync/internal/database"
|
|
)
|
|
|
|
type Server struct {
|
|
db database.Database
|
|
router *mux.Router
|
|
}
|
|
|
|
func NewServer(db database.Database) *Server {
|
|
s := &Server{
|
|
db: db,
|
|
router: mux.NewRouter(),
|
|
}
|
|
|
|
s.setupRoutes()
|
|
return s
|
|
}
|
|
|
|
func (s *Server) setupRoutes() {
|
|
// Static files (embedded)
|
|
s.router.HandleFunc("/", s.handleHome).Methods("GET")
|
|
s.router.HandleFunc("/health", s.handleHealth).Methods("GET")
|
|
|
|
// API routes
|
|
api := s.router.PathPrefix("/api").Subrouter()
|
|
|
|
// Activities
|
|
api.HandleFunc("/activities", s.handleGetActivities).Methods("GET")
|
|
api.HandleFunc("/activities/{id:[0-9]+}", s.handleGetActivity).Methods("GET")
|
|
api.HandleFunc("/activities/search", s.handleSearchActivities).Methods("GET")
|
|
|
|
// Stats
|
|
api.HandleFunc("/stats", s.handleGetStats).Methods("GET")
|
|
api.HandleFunc("/stats/summary", s.handleGetStatsSummary).Methods("GET")
|
|
|
|
// Sync operations
|
|
api.HandleFunc("/sync", s.handleTriggerSync).Methods("POST")
|
|
api.HandleFunc("/sync/status", s.handleGetSyncStatus).Methods("GET")
|
|
|
|
// Configuration
|
|
api.HandleFunc("/config", s.handleGetConfig).Methods("GET")
|
|
api.HandleFunc("/config", s.handleUpdateConfig).Methods("POST")
|
|
}
|
|
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
s.router.ServeHTTP(w, r)
|
|
}
|
|
|
|
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
|
// Serve embedded HTML
|
|
html := getEmbeddedHTML()
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Write([]byte(html))
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
s.writeJSON(w, map[string]string{
|
|
"status": "healthy",
|
|
"service": "GarminSync",
|
|
"timestamp": time.Now().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleGetActivities(w http.ResponseWriter, r *http.Request) {
|
|
// Parse query parameters
|
|
query := r.URL.Query()
|
|
|
|
limit, _ := strconv.Atoi(query.Get("limit"))
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
|
|
offset, _ := strconv.Atoi(query.Get("offset"))
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
// Build filters
|
|
filters := database.ActivityFilters{
|
|
Limit: limit,
|
|
Offset: offset,
|
|
}
|
|
|
|
if activityType := query.Get("activity_type"); activityType != "" {
|
|
filters.ActivityType = activityType
|
|
}
|
|
|
|
if dateFrom := query.Get("date_from"); dateFrom != "" {
|
|
if t, err := time.Parse("2006-01-02", dateFrom); err == nil {
|
|
filters.DateFrom = &t
|
|
}
|
|
}
|
|
|
|
if dateTo := query.Get("date_to"); dateTo != "" {
|
|
if t, err := time.Parse("2006-01-02", dateTo); err == nil {
|
|
filters.DateTo = &t
|
|
}
|
|
}
|
|
|
|
if minDistance := query.Get("min_distance"); minDistance != "" {
|
|
if d, err := strconv.ParseFloat(minDistance, 64); err == nil {
|
|
filters.MinDistance = d * 1000 // Convert km to meters
|
|
}
|
|
}
|
|
|
|
if sortBy := query.Get("sort_by"); sortBy != "" {
|
|
filters.SortBy = sortBy
|
|
}
|
|
|
|
if sortOrder := query.Get("sort_order"); sortOrder != "" {
|
|
filters.SortOrder = sortOrder
|
|
}
|
|
|
|
// Get activities
|
|
activities, err := s.db.FilterActivities(filters)
|
|
if err != nil {
|
|
s.writeError(w, "Failed to get activities", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Convert to API response format
|
|
response := map[string]interface{}{
|
|
"activities": convertActivitiesToAPI(activities),
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}
|
|
|
|
s.writeJSON(w, response)
|
|
}
|
|
|
|
func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
activityID, err := strconv.Atoi(vars["id"])
|
|
if err != nil {
|
|
s.writeError(w, "Invalid activity ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
activity, err := s.db.GetActivity(activityID)
|
|
if err != nil {
|
|
s.writeError(w, "Activity not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
s.writeJSON(w, convertActivityToAPI(*activity))
|
|
}
|
|
|
|
func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) {
|
|
stats, err := s.db.GetStats()
|
|
if err != nil {
|
|
s.writeError(w, "Failed to get statistics", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
s.writeJSON(w, stats)
|
|
}
|
|
|
|
func (s *Server) handleTriggerSync(w http.ResponseWriter, r *http.Request) {
|
|
// This would trigger the sync operation
|
|
// For now, return success
|
|
s.writeJSON(w, map[string]string{
|
|
"status": "sync_started",
|
|
"message": "Sync operation started in background",
|
|
})
|
|
}
|
|
|
|
// Utility functions
|
|
func (s *Server) writeJSON(w http.ResponseWriter, data interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(data)
|
|
}
|
|
|
|
func (s *Server) writeError(w http.ResponseWriter, message string, status int) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"error": message,
|
|
})
|
|
}
|
|
|
|
func convertActivitiesToAPI(activities []database.Activity) []map[string]interface{} {
|
|
result := make([]map[string]interface{}, len(activities))
|
|
for i, activity := range activities {
|
|
result[i] = convertActivityToAPI(activity)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func convertActivityToAPI(activity database.Activity) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"id": activity.ID,
|
|
"activity_id": activity.ActivityID,
|
|
"start_time": activity.StartTime.Format("2006-01-02T15:04:05Z"),
|
|
"activity_type": activity.ActivityType,
|
|
"duration": activity.Duration,
|
|
"duration_formatted": formatDuration(activity.Duration),
|
|
"distance": activity.Distance,
|
|
"distance_km": roundFloat(activity.Distance/1000, 2),
|
|
"max_heart_rate": activity.MaxHeartRate,
|
|
"avg_heart_rate": activity.AvgHeartRate,
|
|
"avg_power": activity.AvgPower,
|
|
"calories": activity.Calories,
|
|
"file_type": activity.FileType,
|
|
"downloaded": activity.Downloaded,
|
|
"created_at": activity.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
"last_sync": activity.LastSync.Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
}
|
|
|
|
func formatDuration(seconds int) string {
|
|
if seconds <= 0 {
|
|
return "-"
|
|
}
|
|
|
|
hours := seconds / 3600
|
|
minutes := (seconds % 3600) / 60
|
|
secs := seconds % 60
|
|
|
|
if hours > 0 {
|
|
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, secs)
|
|
}
|
|
return fmt.Sprintf("%d:%02d", minutes, secs)
|
|
}
|
|
|
|
func roundFloat(val float64, precision int) float64 {
|
|
ratio := math.Pow(10, float64(precision))
|
|
return math.Round(val*ratio) / ratio
|
|
}
|
|
```
|
|
|
|
### 5.2 Embedded HTML Template
|
|
```go
|
|
// internal/web/templates.go
|
|
package web
|
|
|
|
func getEmbeddedHTML() string {
|
|
return `<!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
|
|
```go
|
|
// internal/sync/service.go
|
|
package sync
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"garminsync/internal/database"
|
|
"garminsync/internal/garmin"
|
|
"garminsync/internal/parser"
|
|
)
|
|
|
|
type Service struct {
|
|
db database.Database
|
|
garmin *garmin.Client
|
|
dataDir string
|
|
isRunning bool
|
|
lastSync time.Time
|
|
}
|
|
|
|
type SyncResult struct {
|
|
TotalActivities int
|
|
NewActivities int
|
|
DownloadedFiles int
|
|
UpdatedActivities int
|
|
Errors []string
|
|
Duration time.Duration
|
|
}
|
|
|
|
func NewService(db database.Database, garminClient *garmin.Client) *Service {
|
|
dataDir := os.Getenv("DATA_DIR")
|
|
if dataDir == "" {
|
|
dataDir = "./data"
|
|
}
|
|
|
|
return &Service{
|
|
db: db,
|
|
garmin: garminClient,
|
|
dataDir: dataDir,
|
|
}
|
|
}
|
|
|
|
func (s *Service) IsRunning() bool {
|
|
return s.isRunning
|
|
}
|
|
|
|
func (s *Service) LastSync() time.Time {
|
|
return s.lastSync
|
|
}
|
|
|
|
func (s *Service) SyncActivities() (*SyncResult, error) {
|
|
if s.isRunning {
|
|
return nil, fmt.Errorf("sync already in progress")
|
|
}
|
|
|
|
s.isRunning = true
|
|
defer func() { s.isRunning = false }()
|
|
|
|
startTime := time.Now()
|
|
result := &SyncResult{}
|
|
|
|
log.Println("Starting activity sync...")
|
|
|
|
// Step 1: Get activities from Garmin Connect
|
|
activities, err := s.garmin.GetActivities(0, 1000) // Get last 1000 activities
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get activities from Garmin: %v", err)
|
|
}
|
|
|
|
result.TotalActivities = len(activities)
|
|
log.Printf("Retrieved %d activities from Garmin Connect", len(activities))
|
|
|
|
// Step 2: Process each activity
|
|
for _, garminActivity := range activities {
|
|
if err := s.processActivity(garminActivity, result); err != nil {
|
|
result.Errors = append(result.Errors,
|
|
fmt.Sprintf("Activity %d: %v", garminActivity.ActivityID, err))
|
|
}
|
|
}
|
|
|
|
// Step 3: Download missing files
|
|
if err := s.downloadMissingFiles(result); err != nil {
|
|
result.Errors = append(result.Errors, fmt.Sprintf("Download phase: %v", err))
|
|
}
|
|
|
|
result.Duration = time.Since(startTime)
|
|
s.lastSync = time.Now()
|
|
|
|
log.Printf("Sync completed in %v. New: %d, Downloaded: %d, Updated: %d, Errors: %d",
|
|
result.Duration, result.NewActivities, result.DownloadedFiles,
|
|
result.UpdatedActivities, len(result.Errors))
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) processActivity(garminActivity garmin.GarminActivity, result *SyncResult) error {
|
|
// Check if activity already exists
|
|
existing, err := s.db.GetActivity(garminActivity.ActivityID)
|
|
if err != nil && err.Error() != "activity not found" { // Assuming this error message
|
|
return err
|
|
}
|
|
|
|
var dbActivity *database.Activity
|
|
|
|
if existing == nil {
|
|
// Create new activity
|
|
startTime, err := time.Parse("2006-01-02 15:04:05", garminActivity.StartTimeLocal)
|
|
if err != nil {
|
|
startTime = time.Now() // Fallback
|
|
}
|
|
|
|
dbActivity = &database.Activity{
|
|
ActivityID: garminActivity.ActivityID,
|
|
StartTime: startTime,
|
|
ActivityType: s.mapActivityType(garminActivity.ActivityType),
|
|
Duration: int(garminActivity.Duration),
|
|
Distance: garminActivity.Distance,
|
|
MaxHeartRate: garminActivity.MaxHR,
|
|
AvgHeartRate: garminActivity.AvgHR,
|
|
AvgPower: garminActivity.AvgPower,
|
|
Calories: garminActivity.Calories,
|
|
Downloaded: false,
|
|
CreatedAt: time.Now(),
|
|
LastSync: time.Now(),
|
|
}
|
|
|
|
if err := s.db.CreateActivity(dbActivity); err != nil {
|
|
return err
|
|
}
|
|
|
|
result.NewActivities++
|
|
|
|
} else {
|
|
// Update existing activity if data has changed
|
|
dbActivity = existing
|
|
updated := false
|
|
|
|
if dbActivity.ActivityType != s.mapActivityType(garminActivity.ActivityType) {
|
|
dbActivity.ActivityType = s.mapActivityType(garminActivity.ActivityType)
|
|
updated = true
|
|
}
|
|
|
|
if dbActivity.Duration != int(garminActivity.Duration) {
|
|
dbActivity.Duration = int(garminActivity.Duration)
|
|
updated = true
|
|
}
|
|
|
|
// Update other fields as needed...
|
|
|
|
if updated {
|
|
dbActivity.LastSync = time.Now()
|
|
if err := s.db.UpdateActivity(dbActivity); err != nil {
|
|
return err
|
|
}
|
|
result.UpdatedActivities++
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) downloadMissingFiles(result *SyncResult) error {
|
|
// Get activities that haven't been downloaded
|
|
filters := database.ActivityFilters{
|
|
Downloaded: boolPtr(false),
|
|
Limit: 100, // Process in batches
|
|
}
|
|
|
|
missingActivities, err := s.db.FilterActivities(filters)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Printf("Downloading %d missing activity files...", len(missingActivities))
|
|
|
|
for _, activity := range missingActivities {
|
|
if err := s.downloadActivityFile(&activity, result); err != nil {
|
|
result.Errors = append(result.Errors,
|
|
fmt.Sprintf("Download %d: %v", activity.ActivityID, err))
|
|
continue
|
|
}
|
|
|
|
result.DownloadedFiles++
|
|
|
|
// Rate limiting
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) downloadActivityFile(activity *database.Activity, result *SyncResult) error {
|
|
// Try to download FIT file first
|
|
data, err := s.garmin.DownloadActivity(activity.ActivityID, "fit")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to download FIT file: %v", err)
|
|
}
|
|
|
|
// Detect actual file type
|
|
fileType := parser.DetectFileTypeFromData(data)
|
|
|
|
// Create organized directory structure
|
|
activityDir := filepath.Join(s.dataDir, "activities",
|
|
activity.StartTime.Format("2006"), activity.StartTime.Format("01"))
|
|
|
|
if err := os.MkdirAll(activityDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create directory: %v", err)
|
|
}
|
|
|
|
// Generate filename
|
|
extension := string(fileType)
|
|
if extension == "unknown" {
|
|
extension = "fit" // Default to FIT
|
|
}
|
|
|
|
filename := fmt.Sprintf("activity_%d_%s.%s",
|
|
activity.ActivityID,
|
|
activity.StartTime.Format("20060102_150405"),
|
|
extension)
|
|
|
|
filepath := filepath.Join(activityDir, filename)
|
|
|
|
// Save file
|
|
if err := os.WriteFile(filepath, data, 0644); err != nil {
|
|
return fmt.Errorf("failed to save file: %v", err)
|
|
}
|
|
|
|
// Parse file to get additional metrics
|
|
if err := s.parseAndUpdateActivity(activity, filepath, fileType); err != nil {
|
|
log.Printf("Warning: failed to parse file for activity %d: %v",
|
|
activity.ActivityID, err)
|
|
// Don't return error - file was saved successfully
|
|
}
|
|
|
|
// Update database
|
|
activity.Filename = filepath
|
|
activity.FileType = string(fileType)
|
|
activity.FileSize = int64(len(data))
|
|
activity.Downloaded = true
|
|
activity.LastSync = time.Now()
|
|
|
|
return s.db.UpdateActivity(activity)
|
|
}
|
|
|
|
func (s *Service) parseAndUpdateActivity(activity *database.Activity, filepath string, fileType parser.FileType) error {
|
|
parser := parser.NewParser(fileType)
|
|
if parser == nil {
|
|
return fmt.Errorf("no parser available for file type: %s", fileType)
|
|
}
|
|
|
|
metrics, err := parser.ParseFile(filepath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update activity with parsed metrics (only if not already set)
|
|
if activity.ActivityType == "" && metrics.ActivityType != "" {
|
|
activity.ActivityType = metrics.ActivityType
|
|
}
|
|
|
|
if activity.Duration == 0 && metrics.Duration > 0 {
|
|
activity.Duration = metrics.Duration
|
|
}
|
|
|
|
if activity.Distance == 0 && metrics.Distance > 0 {
|
|
activity.Distance = metrics.Distance
|
|
}
|
|
|
|
if activity.MaxHeartRate == 0 && metrics.MaxHR > 0 {
|
|
activity.MaxHeartRate = metrics.MaxHR
|
|
}
|
|
|
|
if activity.AvgHeartRate == 0 && metrics.AvgHR > 0 {
|
|
activity.AvgHeartRate = metrics.AvgHR
|
|
}
|
|
|
|
if activity.AvgPower == 0 && metrics.AvgPower > 0 {
|
|
activity.AvgPower = metrics.AvgPower
|
|
}
|
|
|
|
if activity.Calories == 0 && metrics.Calories > 0 {
|
|
activity.Calories = metrics.Calories
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) mapActivityType(activityType map[string]interface{}) string {
|
|
if activityType == nil {
|
|
return "other"
|
|
}
|
|
|
|
if typeKey, ok := activityType["typeKey"].(string); ok {
|
|
return typeKey
|
|
}
|
|
|
|
return "other"
|
|
}
|
|
|
|
// Utility function
|
|
func boolPtr(b bool) *bool {
|
|
return &b
|
|
}
|
|
```
|
|
|
|
### 6.2 Update Main Application
|
|
```go
|
|
// main.go - Complete main application with sync integration
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"github.com/robfig/cron/v3"
|
|
|
|
"garminsync/internal/database"
|
|
"garminsync/internal/garmin"
|
|
"garminsync/internal/sync"
|
|
"garminsync/internal/web"
|
|
)
|
|
|
|
type App struct {
|
|
db database.Database
|
|
cron *cron.Cron
|
|
server *http.Server
|
|
garmin *garmin.Client
|
|
syncSvc *sync.Service
|
|
shutdown chan os.Signal
|
|
}
|
|
|
|
func main() {
|
|
log.Println("Starting GarminSync...")
|
|
|
|
app := &App{
|
|
shutdown: make(chan os.Signal, 1),
|
|
}
|
|
|
|
// Initialize components
|
|
if err := app.init(); err != nil {
|
|
log.Fatal("Failed to initialize app:", err)
|
|
}
|
|
|
|
// Start services
|
|
app.start()
|
|
|
|
log.Println("GarminSync is running...")
|
|
log.Println("Web interface: http://localhost:8888")
|
|
log.Println("Press Ctrl+C to shutdown")
|
|
|
|
// Wait for shutdown signal
|
|
signal.Notify(app.shutdown, os.Interrupt, syscall.SIGTERM)
|
|
<-app.shutdown
|
|
|
|
// Graceful shutdown
|
|
app.stop()
|
|
}
|
|
|
|
func (app *App) init() error {
|
|
var err error
|
|
|
|
// Initialize database
|
|
app.db, err = database.NewSQLiteDB("./data/garmin.db")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Initialize Garmin client
|
|
app.garmin = garmin.NewClient()
|
|
|
|
// Initialize sync service
|
|
app.syncSvc = sync.NewService(app.db, app.garmin)
|
|
|
|
// Setup cron scheduler
|
|
app.cron = cron.New()
|
|
|
|
// Setup HTTP server
|
|
webServer := web.NewServer(app.db)
|
|
|
|
// Add sync endpoint to web server
|
|
app.setupSyncEndpoints(webServer)
|
|
|
|
app.server = &http.Server{
|
|
Addr: ":8888",
|
|
Handler: webServer,
|
|
ReadTimeout: 15 * time.Second,
|
|
WriteTimeout: 15 * time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (app *App) setupSyncEndpoints(webServer *web.Server) {
|
|
// This would extend the web server with sync-specific endpoints
|
|
// For now, we'll handle it in the main sync trigger
|
|
}
|
|
|
|
func (app *App) start() {
|
|
// Schedule hourly sync
|
|
app.cron.AddFunc("@hourly", func() {
|
|
log.Println("Starting scheduled sync...")
|
|
if result, err := app.syncSvc.SyncActivities(); err != nil {
|
|
log.Printf("Scheduled sync failed: %v", err)
|
|
} else {
|
|
log.Printf("Scheduled sync completed: %+v", result)
|
|
}
|
|
})
|
|
app.cron.Start()
|
|
|
|
// Start web server
|
|
go func() {
|
|
log.Printf("Web server starting on %s", app.server.Addr)
|
|
if err := app.server.ListenAndServe(); err != http.ErrServerClosed {
|
|
log.Printf("Web server error: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Perform initial sync if no activities exist
|
|
go func() {
|
|
time.Sleep(2 * time.Second) // Wait for server to start
|
|
|
|
stats, err := app.db.GetStats()
|
|
if err != nil {
|
|
log.Printf("Failed to get stats: %v", err)
|
|
return
|
|
}
|
|
|
|
if stats.Total == 0 {
|
|
log.Println("No activities found, performing initial sync...")
|
|
if result, err := app.syncSvc.SyncActivities(); err != nil {
|
|
log.Printf("Initial sync failed: %v", err)
|
|
} else {
|
|
log.Printf("Initial sync completed: %+v", result)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (app *App) stop() {
|
|
log.Println("Shutting down GarminSync...")
|
|
|
|
// Stop cron scheduler
|
|
if app.cron != nil {
|
|
app.cron.Stop()
|
|
}
|
|
|
|
// Stop web server
|
|
if app.server != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if err := app.server.Shutdown(ctx); err != nil {
|
|
log.Printf("Server shutdown error: %v", err)
|
|
}
|
|
}
|
|
|
|
// Close database
|
|
if app.db != nil {
|
|
app.db.Close()
|
|
}
|
|
|
|
log.Println("Shutdown complete")
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 7: Build & Deployment (Week 5)
|
|
|
|
### 7.1 Build Script
|
|
```bash
|
|
#!/bin/bash
|
|
# build.sh - Cross-platform build script
|
|
|
|
APP_NAME="garminsync"
|
|
VERSION="1.0.0"
|
|
BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
|
|
|
# Build flags
|
|
LDFLAGS="-X main.version=${VERSION} -X main.buildTime=${BUILD_TIME} -X main.gitCommit=${GIT_COMMIT}"
|
|
|
|
echo "Building GarminSync v${VERSION}..."
|
|
|
|
# Create build directory
|
|
mkdir -p dist
|
|
|
|
# Build for different platforms
|
|
platforms=(
|
|
"linux/amd64"
|
|
"linux/arm64"
|
|
"darwin/amd64"
|
|
"darwin/arm64"
|
|
"windows/amd64"
|
|
)
|
|
|
|
for platform in "${platforms[@]}"
|
|
do
|
|
platform_split=(${platform//\// })
|
|
GOOS=${platform_split[0]}
|
|
GOARCH=${platform_split[1]}
|
|
|
|
output_name="${APP_NAME}-${GOOS}-${GOARCH}"
|
|
if [ $GOOS = "windows" ]; then
|
|
output_name+='.exe'
|
|
fi
|
|
|
|
echo "Building for $GOOS/$GOARCH..."
|
|
|
|
env CGO_ENABLED=1 GOOS=$GOOS GOARCH=$GOARCH go build \
|
|
-ldflags="${LDFLAGS}" \
|
|
-o "dist/${output_name}" \
|
|
.
|
|
|
|
if [ $? -ne 0 ]; then
|
|
echo "Build failed for $GOOS/$GOARCH"
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
echo "Build completed successfully!"
|
|
ls -la dist/
|
|
```
|
|
|
|
### 7.2 Docker Support
|
|
```dockerfile
|
|
# Dockerfile - Multi-stage build for minimal image
|
|
FROM golang:1.21-alpine AS builder
|
|
|
|
# Install build dependencies
|
|
RUN apk add --no-cache gcc musl-dev sqlite-dev
|
|
|
|
WORKDIR /app
|
|
|
|
# Copy go mod files
|
|
COPY go.mod go.sum ./
|
|
RUN go mod download
|
|
|
|
# Copy source code
|
|
COPY . .
|
|
|
|
# Build the application
|
|
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o garminsync .
|
|
|
|
# Final stage
|
|
FROM alpine:latest
|
|
|
|
# Install runtime dependencies
|
|
RUN apk --no-cache add ca-certificates sqlite
|
|
|
|
WORKDIR /app
|
|
|
|
# Copy binary from builder
|
|
COPY --from=builder /app/garminsync .
|
|
|
|
# Create data directory
|
|
RUN mkdir -p /data
|
|
|
|
# Set environment variables
|
|
ENV DATA_DIR=/data
|
|
ENV GIN_MODE=release
|
|
|
|
# Expose port
|
|
EXPOSE 8888
|
|
|
|
# Health check
|
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8888/health || exit 1
|
|
|
|
# Run the application
|
|
CMD ["./garminsync"]
|
|
```
|
|
|
|
### 7.3 Docker Compose
|
|
```yaml
|
|
# docker-compose.yml - Single service deployment
|
|
version: '3.8'
|
|
|
|
services:
|
|
garminsync:
|
|
build: .
|
|
container_name: garminsync
|
|
ports:
|
|
- "8888:8888"
|
|
environment:
|
|
- GARMIN_EMAIL=${GARMIN_EMAIL}
|
|
- GARMIN_PASSWORD=${GARMIN_PASSWORD}
|
|
- DATA_DIR=/data
|
|
volumes:
|
|
- ./data:/data
|
|
- /etc/localtime:/etc/localtime:ro
|
|
restart: unless-stopped
|
|
healthcheck:
|
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8888/health"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
logging:
|
|
driver: "json-file"
|
|
options:
|
|
max-size: "10m"
|
|
max-file: "3"
|
|
```
|
|
|
|
### 7.4 Installation Script
|
|
```bash
|
|
#!/bin/bash
|
|
# install.sh - Simple installation script
|
|
|
|
set -e
|
|
|
|
APP_NAME="garminsync"
|
|
INSTALL_DIR="/usr/local/bin"
|
|
SERVICE_DIR="/etc/systemd/system"
|
|
|
|
# Detect architecture
|
|
ARCH=$(uname -m)
|
|
case $ARCH in
|
|
x86_64)
|
|
ARCH="amd64"
|
|
;;
|
|
aarch64|arm64)
|
|
ARCH="arm64"
|
|
;;
|
|
*)
|
|
echo "Unsupported architecture: $ARCH"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
# Detect OS
|
|
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
|
|
|
BINARY_NAME="${APP_NAME}-${OS}-${ARCH}"
|
|
DOWNLOAD_URL="https://github.com/yourusername/garminsync/releases/latest/download/${BINARY_NAME}"
|
|
|
|
echo "Installing GarminSync for ${OS}/${ARCH}..."
|
|
|
|
# Download binary
|
|
echo "Downloading ${BINARY_NAME}..."
|
|
curl -L -o "/tmp/${BINARY_NAME}" "$DOWNLOAD_URL"
|
|
chmod +x "/tmp/${BINARY_NAME}"
|
|
|
|
# Install binary
|
|
echo "Installing to ${INSTALL_DIR}..."
|
|
sudo mv "/tmp/${BINARY_NAME}" "${INSTALL_DIR}/${APP_NAME}"
|
|
|
|
# Create data directory
|
|
sudo mkdir -p /var/lib/garminsync
|
|
sudo chown $USER:$USER /var/lib/garminsync
|
|
|
|
# Create systemd service (Linux only)
|
|
if [ "$OS" = "linux" ] && [ -d "$SERVICE_DIR" ]; then
|
|
echo "Creating systemd service..."
|
|
|
|
sudo tee "${SERVICE_DIR}/garminsync.service" > /dev/null <<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
|
|
```go
|
|
// 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
|
|
```markdown
|
|
# 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)
|
|
|
|
```yaml
|
|
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
|
|
|
|
```bash
|
|
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
|
|
```bash
|
|
git clone https://github.com/yourusername/garminsync.git
|
|
cd garminsync
|
|
go build -o garminsync .
|
|
```
|
|
|
|
### Cross-compile
|
|
```bash
|
|
# 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
|
|
```bash
|
|
export LOG_LEVEL=debug
|
|
./garminsync
|
|
```
|
|
|
|
### Reset Database
|
|
```bash
|
|
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](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**
|
|
```bash
|
|
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**
|
|
```bash
|
|
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**
|
|
```bash
|
|
# 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**
|
|
```bash
|
|
# 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
|
|
```python
|
|
# 500-line single Python file
|
|
# Pros: Familiar, quick migration
|
|
# Cons: Still requires Python runtime, dependencies
|
|
```
|
|
|
|
### Option B: Rust Single Binary
|
|
```rust
|
|
// Similar benefits to Go
|
|
// Pros: Memory safety, performance
|
|
// Cons: Steeper learning curve, longer compile times
|
|
```
|
|
|
|
### Option C: Node.js Single Executable
|
|
```javascript
|
|
// 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! |