mirror of
https://github.com/sstent/aicyclingcoach-go.git
synced 2025-12-05 23:51:37 +00:00
45 KiB
45 KiB
Fitness TUI Implementation Guide
Table of Contents
- Project Setup
- Development Environment
- Core Dependencies
- Project Structure
- Implementation Phases
- Detailed Implementation Steps
- Testing Strategy
- Deployment
Project Setup
Initialize Go Module
mkdir fitness-tui
cd fitness-tui
go mod init github.com/yourusername/fitness-tui
Create Directory Structure
mkdir -p {cmd,internal/{tui/{screens,components,models},garmin,analysis,storage,charts,config}}
mkdir -p {test,docs,examples}
touch cmd/main.go
touch internal/tui/app.go
Git Setup
git init
echo "# Fitness TUI" > README.md
Create .gitignore:
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
fitness-tui
# Test binary
*.test
# Output of the go coverage tool
*.out
# Go workspace file
go.work
# IDE
.vscode/
.idea/
# Config files with credentials
config.yaml
*.env
# Build artifacts
dist/
build/
Development Environment
Required Go Version
- Go 1.21+ (for better error handling and performance)
Development Tools
# Install development tools
go install golang.org/x/tools/cmd/goimports@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
go install github.com/air-verse/air@latest # Hot reload during development
IDE Configuration
Add to .vscode/settings.json:
{
"go.useLanguageServer": true,
"go.formatTool": "goimports",
"go.lintTool": "golangci-lint",
"go.testFlags": ["-v"],
"go.testTimeout": "10s"
}
Core Dependencies
Add Dependencies
# TUI Framework
go get github.com/charmbracelet/bubbletea@latest
go get github.com/charmbracelet/lipgloss@latest
go get github.com/charmbracelet/bubbles@latest
# Garmin Integration (check latest version of go-garth)
go get github.com/sstent/go-garth@latest
# HTTP Client for OpenRouter
go get github.com/go-resty/resty/v2@latest
# Configuration
go get github.com/spf13/viper@latest
go get github.com/spf13/cobra@latest
# GPX Processing
go get github.com/tkrajina/gpxgo/gpx@latest
# File System Operations
go get github.com/fsnotify/fsnotify@latest
# Testing
go get github.com/stretchr/testify@latest
Verify Dependencies
go mod tidy
go mod verify
Project Structure
fitness-tui/
├── cmd/
│ └── main.go # Application entry point
├── internal/
│ ├── config/
│ │ ├── config.go # Configuration management
│ │ └── defaults.go # Default configuration values
│ ├── tui/
│ │ ├── app.go # Main TUI application
│ │ ├── screens/
│ │ │ ├── activity_list.go
│ │ │ ├── activity_detail.go
│ │ │ ├── route_list.go
│ │ │ ├── plan_list.go
│ │ │ └── help.go
│ │ ├── components/
│ │ │ ├── chart.go # ASCII chart components
│ │ │ ├── list.go # List components
│ │ │ └── modal.go # Modal dialogs
│ │ └── models/
│ │ ├── activity.go
│ │ ├── route.go
│ │ ├── plan.go
│ │ └── analysis.go
│ ├── garmin/
│ │ ├── client.go # Garmin API client wrapper
│ │ ├── sync.go # Activity synchronization
│ │ └── auth.go # Authentication handling
│ ├── analysis/
│ │ ├── llm.go # OpenRouter LLM client
│ │ ├── cache.go # Analysis caching
│ │ └── prompts.go # LLM prompt templates
│ ├── storage/
│ │ ├── files.go # File-based storage operations
│ │ ├── activities.go # Activity storage management
│ │ ├── routes.go # Route storage management
│ │ └── migrations.go # Data format migrations
│ └── charts/
│ ├── ascii.go # ASCII chart generation
│ └── sparkline.go # Sparkline utilities
├── test/
│ ├── fixtures/ # Test data files
│ └── integration/ # Integration tests
├── docs/
├── examples/
│ └── sample-config.yaml
└── README.md
Implementation Phases
Phase 1: Core Foundation (Week 1-2)
- Project setup and configuration management
- Basic TUI structure with bubbletea
- File storage system
- Garmin client integration
Phase 2: Activity Management (Week 3-4)
- Activity data models
- Activity list screen
- Activity detail screen
- Basic ASCII charts
Phase 3: LLM Analysis (Week 5-6)
- OpenRouter integration
- Analysis caching system
- Analysis display in TUI
Phase 4: Routes & Polish (Week 7-8)
- GPX file handling
- Route management screens
- Error handling and edge cases
- Documentation and testing
Detailed Implementation Steps
Step 1: Configuration System
internal/config/config.go
package config
import (
"os"
"path/filepath"
"github.com/spf13/viper"
)
type Config struct {
Garmin struct {
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
} `mapstructure:"garmin"`
OpenRouter struct {
APIKey string `mapstructure:"api_key"`
Model string `mapstructure:"model"`
BaseURL string `mapstructure:"base_url"`
} `mapstructure:"openrouter"`
Storage struct {
DataDir string `mapstructure:"data_dir"`
} `mapstructure:"storage"`
}
func Load() (*Config, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
configDir := filepath.Join(homeDir, ".fitness-tui")
if err := os.MkdirAll(configDir, 0755); err != nil {
return nil, err
}
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(configDir)
// Set defaults
setDefaults()
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
// Create default config file
if err := viper.SafeWriteConfig(); err != nil {
return nil, err
}
} else {
return nil, err
}
}
var config Config
if err := viper.Unmarshal(&config); err != nil {
return nil, err
}
return &config, nil
}
func setDefaults() {
homeDir, _ := os.UserHomeDir()
viper.SetDefault("storage.data_dir", filepath.Join(homeDir, ".fitness-tui"))
viper.SetDefault("openrouter.model", "deepseek/deepseek-r1-05028:free")
viper.SetDefault("openrouter.base_url", "https://openrouter.ai/api/v1")
}
Step 2: Data Models
internal/tui/models/activity.go
package models
import (
"time"
"github.com/tkrajina/gpxgo/gpx"
)
type Activity struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Date time.Time `json:"date"`
Duration time.Duration `json:"duration"`
Distance float64 `json:"distance"` // meters
Metrics ActivityMetrics `json:"metrics"`
GPXData *gpx.GPX `json:"-"`
GPXPath string `json:"gpx_path,omitempty"`
Analysis *Analysis `json:"analysis,omitempty"`
}
type ActivityMetrics struct {
AvgHeartRate int `json:"avg_heart_rate,omitempty"`
MaxHeartRate int `json:"max_heart_rate,omitempty"`
AvgPace float64 `json:"avg_pace,omitempty"` // seconds per km
AvgSpeed float64 `json:"avg_speed,omitempty"` // km/h
ElevationGain float64 `json:"elevation_gain,omitempty"` // meters
ElevationLoss float64 `json:"elevation_loss,omitempty"` // meters
Calories int `json:"calories,omitempty"`
AvgCadence int `json:"avg_cadence,omitempty"`
AvgPower int `json:"avg_power,omitempty"`
Temperature float64 `json:"temperature,omitempty"` // celsius
}
type Analysis struct {
ActivityID string `json:"activity_id"`
WorkoutType string `json:"workout_type"`
GeneratedAt time.Time `json:"generated_at"`
Content string `json:"content"`
Insights []string `json:"insights"`
FilePath string `json:"file_path"`
}
// Helper methods
func (a *Activity) FormattedDuration() string {
hours := int(a.Duration.Hours())
minutes := int(a.Duration.Minutes()) % 60
seconds := int(a.Duration.Seconds()) % 60
if hours > 0 {
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds)
}
return fmt.Sprintf("%d:%02d", minutes, seconds)
}
func (a *Activity) FormattedDistance() string {
km := a.Distance / 1000
if km >= 10 {
return fmt.Sprintf("%.1fkm", km)
}
return fmt.Sprintf("%.2fkm", km)
}
func (a *Activity) FormattedPace() string {
if a.Metrics.AvgPace <= 0 {
return "--:--"
}
minutes := int(a.Metrics.AvgPace / 60)
seconds := int(a.Metrics.AvgPace) % 60
return fmt.Sprintf("%d:%02d/km", minutes, seconds)
}
Step 3: Storage System
internal/storage/activities.go
package storage
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"sort"
"time"
"github.com/yourusername/fitness-tui/internal/tui/models"
)
type ActivityStorage struct {
dataDir string
}
func NewActivityStorage(dataDir string) *ActivityStorage {
activitiesDir := filepath.Join(dataDir, "activities")
os.MkdirAll(activitiesDir, 0755)
return &ActivityStorage{
dataDir: dataDir,
}
}
func (s *ActivityStorage) Save(activity *models.Activity) error {
activitiesDir := filepath.Join(s.dataDir, "activities")
filename := fmt.Sprintf("%s-%s.json",
activity.Date.Format("2006-01-02"),
sanitizeFilename(activity.Name))
filepath := filepath.Join(activitiesDir, filename)
data, err := json.MarshalIndent(activity, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal activity: %w", err)
}
if err := ioutil.WriteFile(filepath, data, 0644); err != nil {
return fmt.Errorf("failed to write activity file: %w", err)
}
return nil
}
func (s *ActivityStorage) LoadAll() ([]*models.Activity, error) {
activitiesDir := filepath.Join(s.dataDir, "activities")
files, err := ioutil.ReadDir(activitiesDir)
if err != nil {
if os.IsNotExist(err) {
return []*models.Activity{}, nil
}
return nil, err
}
var activities []*models.Activity
for _, file := range files {
if filepath.Ext(file.Name()) != ".json" {
continue
}
activity, err := s.loadActivity(filepath.Join(activitiesDir, file.Name()))
if err != nil {
// Log error but continue loading other activities
continue
}
activities = append(activities, activity)
}
// Sort by date (newest first)
sort.Slice(activities, func(i, j int) bool {
return activities[i].Date.After(activities[j].Date)
})
return activities, nil
}
func (s *ActivityStorage) loadActivity(filePath string) (*models.Activity, error) {
data, err := ioutil.ReadFile(filePath)
if err != nil {
return nil, err
}
var activity models.Activity
if err := json.Unmarshal(data, &activity); err != nil {
return nil, err
}
return &activity, nil
}
func sanitizeFilename(name string) string {
// Replace invalid filename characters
replacer := strings.NewReplacer(
"/", "-",
"\\", "-",
":", "-",
"*", "-",
"?", "-",
"\"", "-",
"<", "-",
">", "-",
"|", "-",
" ", "-",
)
return replacer.Replace(name)
}
Step 4: TUI Application Structure
internal/tui/app.go
package tui
import (
"fmt"
"log"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/yourusername/fitness-tui/internal/config"
"github.com/yourusername/fitness-tui/internal/tui/screens"
"github.com/yourusername/fitness-tui/internal/storage"
)
type Screen int
const (
ScreenMain Screen = iota
ScreenActivityList
ScreenActivityDetail
ScreenRouteList
ScreenPlanList
ScreenHelp
)
type App struct {
config *config.Config
activityStorage *storage.ActivityStorage
currentScreen Screen
screens map[Screen]tea.Model
width int
height int
statusMessage string
errorMessage string
}
func NewApp(cfg *config.Config) *App {
activityStorage := storage.NewActivityStorage(cfg.Storage.DataDir)
app := &App{
config: cfg,
activityStorage: activityStorage,
currentScreen: ScreenMain,
screens: make(map[Screen]tea.Model),
}
// Initialize screens
app.screens[ScreenActivityList] = screens.NewActivityList(activityStorage)
app.screens[ScreenHelp] = screens.NewHelp()
return app
}
func (a *App) Init() tea.Cmd {
return tea.SetWindowTitle("Fitness TUI")
}
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
a.width = msg.Width
a.height = msg.Height
return a, nil
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
if a.currentScreen == ScreenMain {
return a, tea.Quit
}
// Go back to main screen
a.currentScreen = ScreenMain
return a, nil
case "h", "?":
a.currentScreen = ScreenHelp
return a, nil
case "a":
if a.currentScreen == ScreenMain {
a.currentScreen = ScreenActivityList
return a, a.screens[ScreenActivityList].Init()
}
case "r":
if a.currentScreen == ScreenMain {
a.currentScreen = ScreenRouteList
return a, nil
}
case "p":
if a.currentScreen == ScreenMain {
a.currentScreen = ScreenPlanList
return a, nil
}
}
}
// Delegate to current screen
if screen, exists := a.screens[a.currentScreen]; exists {
updatedScreen, cmd := screen.Update(msg)
a.screens[a.currentScreen] = updatedScreen
return a, cmd
}
return a, nil
}
func (a *App) View() string {
if a.currentScreen == ScreenMain {
return a.renderMainMenu()
}
if screen, exists := a.screens[a.currentScreen]; exists {
return screen.View()
}
return "Unknown screen"
}
func (a *App) renderMainMenu() string {
style := lipgloss.NewStyle().
Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62"))
menu := fmt.Sprintf(`Fitness TUI v1.0
Navigation:
[A] Activities - View and analyze your workouts
[R] Routes - Manage GPX routes and segments
[P] Plans - Training plans and workouts
[S] Sync - Sync with Garmin
[H] Help - Show help
[Q] Quit - Exit application
Status: %s`, a.statusMessage)
return style.Render(menu)
}
func (a *App) Run() error {
p := tea.NewProgram(a, tea.WithAltScreen())
_, err := p.Run()
return err
}
Step 5: Activity List Screen
internal/tui/screens/activity_list.go
package screens
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/yourusername/fitness-tui/internal/tui/models"
"github.com/yourusername/fitness-tui/internal/storage"
)
type ActivityList struct {
list list.Model
storage *storage.ActivityStorage
width int
height int
}
type activityItem struct {
activity *models.Activity
}
func (i activityItem) FilterValue() string { return i.activity.Name }
func (i activityItem) Title() string {
return fmt.Sprintf("%s - %s",
i.activity.Date.Format("2006-01-02"),
i.activity.Name)
}
func (i activityItem) Description() string {
return fmt.Sprintf("%s %s %s",
i.activity.FormattedDistance(),
i.activity.FormattedDuration(),
i.activity.FormattedPace())
}
func NewActivityList(storage *storage.ActivityStorage) *ActivityList {
items := []list.Item{}
delegate := list.NewDefaultDelegate()
delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.
Foreground(lipgloss.Color("170")).
BorderLeftForeground(lipgloss.Color("170"))
delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.
Foreground(lipgloss.Color("243"))
l := list.New(items, delegate, 0, 0)
l.Title = "Activities"
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
l.Styles.Title = lipgloss.NewStyle().
MarginLeft(2).
Foreground(lipgloss.Color("62")).
Bold(true)
return &ActivityList{
list: l,
storage: storage,
}
}
func (m *ActivityList) Init() tea.Cmd {
return m.loadActivities
}
func (m *ActivityList) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.list.SetWidth(msg.Width)
m.list.SetHeight(msg.Height - 4) // Reserve space for instructions
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "s":
return m, m.syncActivities
case "enter":
if selectedItem := m.list.SelectedItem(); selectedItem != nil {
// TODO: Navigate to activity detail
return m, nil
}
}
case activitiesLoadedMsg:
items := make([]list.Item, len(msg.activities))
for i, activity := range msg.activities {
items[i] = activityItem{activity: activity}
}
m.list.SetItems(items)
return m, nil
case syncCompleteMsg:
return m, m.loadActivities
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m *ActivityList) View() string {
instructions := lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1).
Render("↑↓ navigate • enter view • s sync • q back")
return fmt.Sprintf("%s\n%s", m.list.View(), instructions)
}
// Commands
type activitiesLoadedMsg struct {
activities []*models.Activity
}
type syncCompleteMsg struct{}
func (m *ActivityList) loadActivities() tea.Msg {
activities, err := m.storage.LoadAll()
if err != nil {
// TODO: Handle error properly
return activitiesLoadedMsg{activities: []*models.Activity{}}
}
return activitiesLoadedMsg{activities: activities}
}
func (m *ActivityList) syncActivities() tea.Msg {
// TODO: Implement Garmin sync
return syncCompleteMsg{}
}
Step 6: ASCII Charts
internal/charts/ascii.go
package charts
import (
"fmt"
"math"
"strings"
)
const (
// Unicode block characters for charts
BlockEmpty = " "
Block1 = "▁"
Block2 = "▂"
Block3 = "▃"
Block4 = "▄"
Block5 = "▅"
Block6 = "▆"
Block7 = "▇"
Block8 = "█"
)
var blockChars = []string{BlockEmpty, Block1, Block2, Block3, Block4, Block5, Block6, Block7, Block8}
// SparklineChart generates a simple sparkline from data points
func SparklineChart(data []float64, width int) string {
if len(data) == 0 {
return strings.Repeat(BlockEmpty, width)
}
// Find min/max for normalization
min, max := data[0], data[0]
for _, v := range data {
if v < min {
min = v
}
if v > max {
max = v
}
}
// Handle case where all values are the same
if min == max {
return strings.Repeat(Block4, width)
}
// Downsample data to fit width
sampledData := sampleData(data, width)
var result strings.Builder
for _, value := range sampledData {
// Normalize to 0-8 range (9 levels including empty)
normalized := (value - min) / (max - min)
level := int(normalized * 8)
if level > 8 {
level = 8
}
result.WriteString(blockChars[level])
}
return result.String()
}
// LineChart generates a multi-line ASCII chart
func LineChart(data []float64, width, height int, title string) string {
if len(data) == 0 {
return fmt.Sprintf("%s\n%s", title, strings.Repeat("-", width))
}
// Find min/max for scaling
min, max := data[0], data[0]
for _, v := range data {
if v < min {
min = v
}
if v > max {
max = v
}
}
if min == max {
// All values are the same
line := strings.Repeat("-", width)
var chart strings.Builder
chart.WriteString(fmt.Sprintf("%s (%.2f)\n", title, min))
for i := 0; i < height; i++ {
if i == height/2 {
chart.WriteString(line + "\n")
} else {
chart.WriteString(strings.Repeat(" ", width) + "\n")
}
}
return chart.String()
}
// Sample data to fit width
sampledData := sampleData(data, width)
// Create chart grid
grid := make([][]rune, height)
for i := range grid {
grid[i] = make([]rune, width)
for j := range grid[i] {
grid[i][j] = ' '
}
}
// Plot data points
for x, value := range sampledData {
// Scale to chart height (inverted because we draw top to bottom)
normalized := (value - min) / (max - min)
y := height - 1 - int(normalized*float64(height-1))
if y < 0 {
y = 0
}
if y >= height {
y = height - 1
}
if x < width {
grid[y][x] = '•'
}
}
// Render chart
var chart strings.Builder
chart.WriteString(fmt.Sprintf("%s (%.2f - %.2f)\n", title, min, max))
for _, row := range grid {
chart.WriteString(string(row) + "\n")
}
return chart.String()
}
// sampleData downsamples data array to target length
func sampleData(data []float64, targetLength int) []float64 {
if len(data) <= targetLength {
return data
}
sampled := make([]float64, targetLength)
ratio := float64(len(data)) / float64(targetLength)
for i := 0; i < targetLength; i++ {
index := int(float64(i) * ratio)
if index >= len(data) {
index = len(data) - 1
}
sampled[i] = data[index]
}
return sampled
}
// ChartData represents different types of chart data
type ChartData struct {
Label string
Values []float64
Unit string
}
// MultiChart renders multiple charts in a stacked view
func MultiChart(charts []ChartData, width int) string {
var result strings.Builder
for i, chart := range charts {
if i > 0 {
result.WriteString("\n")
}
title := chart.Label
if chart.Unit != "" {
title += fmt.Sprintf(" (%s)", chart.Unit)
}
sparkline := SparklineChart(chart.Values, width)
result.WriteString(fmt.Sprintf("%-12s %s", title+":", sparkline))
}
return result.String()
}
Step 7: Main Entry Point
cmd/main.go
package main
import (
"fmt"
"log"
"os"
"github.com/yourusername/fitness-tui/internal/config"
"github.com/yourusername/fitness-tui/internal/tui"
)
func main() {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Validate required configuration
if err := validateConfig(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Configuration error: %v\n", err)
fmt.Fprintf(os.Stderr, "Please edit ~/.fitness-tui/config.yaml and add your credentials\n")
os.Exit(1)
}
// Create and run TUI application
app := tui.NewApp(cfg)
if err := app.Run(); err != nil {
log.Fatalf("Application error: %v", err)
}
}
func validateConfig(cfg *config.Config) error {
if cfg.Garmin.Username == "" {
return fmt.Errorf("Garmin username is required")
}
if cfg.Garmin.Password == "" {
return fmt.Errorf("Garmin password is required")
}
if cfg.OpenRouter.APIKey == "" {
return fmt.Errorf("OpenRouter API key is required for analysis features")
}
return nil
}
Testing Strategy
Unit Tests Structure
# Create test files
touch internal/storage/activities_test.go
touch internal/charts/ascii_test.go
touch internal/tui/models/activity_test.go
Example Test File: internal/charts/ascii_test.go
package charts
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSparklineChart(t *testing.T) {
tests := []struct {
name string
data []float64
width int
expected string
}{
{
name: "empty data",
data: []float64{},
width: 10,
expected: " ", // 10 spaces
},
{
name: "single value",
data: []float64{50},
width: 5,
expected: "▄▄▄▄▄",
},
{
name: "ascending values",
data: []float64{1, 2, 3, 4, 5},
width: 5,
expected: "▁▂▄▆█",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SparklineChart(tt.data, tt.width)
assert.Equal(t, tt.expected, result)
assert.Len(t, result, tt.width)
})
}
}
func TestLineChart(t *testing.T) {
data := []float64{1, 3, 2, 5, 4}
result := LineChart(data, 10, 5, "Test Chart")
// Check that it returns a multi-line string
lines := strings.Split(result, "\n")
assert.True(t, len(lines) >= 5)
// Check title is included
assert.Contains(t, lines[0], "Test Chart")
}
Integration Tests
test/integration/storage_test.go
package integration
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yourusername/fitness-tui/internal/storage"
"github.com/yourusername/fitness-tui/internal/tui/models"
)
func TestActivityStorageIntegration(t *testing.T) {
// Create temporary directory
tmpDir, err := os.MkdirTemp("", "fitness-tui-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
storage := storage.NewActivityStorage(tmpDir)
// Create test activity
activity := &models.Activity{
ID: "test-123",
Name: "Test Run",
Type: "running",
Date: time.Now(),
Duration: 30 * time.Minute,
Distance: 5000, // 5km
Metrics: models.ActivityMetrics{
AvgHeartRate: 150,
AvgPace: 300, // 5:00/km
},
}
// Test save
err = storage.Save(activity)
require.NoError(t, err)
// Test load
activities, err := storage.LoadAll()
require.NoError(t, err)
require.Len(t, activities, 1)
loaded := activities[0]
assert.Equal(t, activity.ID, loaded.ID)
assert.Equal(t, activity.Name, loaded.Name)
assert.Equal(t, activity.Distance, loaded.Distance)
}
Makefile for Development
Makefile
.PHONY: build test run clean install dev lint fmt
# Go parameters
GOCMD=go
GOBUILD=$(GOCMD) build
GOCLEAN=$(GOCMD) clean
GOTEST=$(GOCMD) test
GOGET=$(GOCMD) get
GOMOD=$(GOCMD) mod
BINARY_NAME=fitness-tui
# Build the application
build:
$(GOBUILD) -o $(BINARY_NAME) -v ./cmd
# Run tests
test:
$(GOTEST) -v ./...
# Run tests with coverage
test-coverage:
$(GOTEST) -v -coverprofile=coverage.out ./...
$(GOCMD) tool cover -html=coverage.out -o coverage.html
# Run the application
run:
$(GOBUILD) -o $(BINARY_NAME) -v ./cmd
./$(BINARY_NAME)
# Development mode with hot reload
dev:
air
# Clean build artifacts
clean:
$(GOCLEAN)
rm -f $(BINARY_NAME)
rm -f coverage.out coverage.html
# Install dependencies
install:
$(GOMOD) tidy
$(GOMOD) download
# Lint the code
lint:
golangci-lint run
# Format the code
fmt:
goimports -w .
$(GOCMD) fmt ./...
# Build for multiple platforms
build-all:
GOOS=linux GOARCH=amd64 $(GOBUILD) -o dist/$(BINARY_NAME)-linux-amd64 ./cmd
GOOS=windows GOARCH=amd64 $(GOBUILD) -o dist/$(BINARY_NAME)-windows-amd64.exe ./cmd
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o dist/$(BINARY_NAME)-darwin-amd64 ./cmd
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o dist/$(BINARY_NAME)-darwin-arm64 ./cmd
# Install the binary
install-bin: build
cp $(BINARY_NAME) $(GOPATH)/bin/
Step 8: Garmin Integration
internal/garmin/client.go
package garmin
import (
"context"
"fmt"
"time"
"github.com/sstent/go-garth"
"github.com/yourusername/fitness-tui/internal/tui/models"
)
type Client struct {
client *garth.Client
username string
password string
}
func NewClient(username, password string) *Client {
return &Client{
username: username,
password: password,
}
}
func (c *Client) Connect() error {
client := garth.NewClient(garth.Credentials{
Username: c.username,
Password: c.password,
})
if err := client.Login(); err != nil {
return fmt.Errorf("failed to login to Garmin: %w", err)
}
c.client = client
return nil
}
func (c *Client) GetActivities(ctx context.Context, limit int) ([]*models.Activity, error) {
if c.client == nil {
return nil, fmt.Errorf("client not connected")
}
// Get activities from Garmin
gActivities, err := c.client.GetActivities(ctx, 0, limit)
if err != nil {
return nil, fmt.Errorf("failed to fetch activities: %w", err)
}
var activities []*models.Activity
for _, gActivity := range gActivities {
activity := c.convertActivity(gActivity)
activities = append(activities, activity)
}
return activities, nil
}
func (c *Client) GetActivityDetails(ctx context.Context, activityID string) (*models.Activity, error) {
if c.client == nil {
return nil, fmt.Errorf("client not connected")
}
details, err := c.client.GetActivityDetails(ctx, activityID)
if err != nil {
return nil, fmt.Errorf("failed to fetch activity details: %w", err)
}
activity := c.convertActivityWithDetails(details)
return activity, nil
}
func (c *Client) convertActivity(gActivity interface{}) *models.Activity {
// This function needs to be implemented based on the actual
// structure returned by go-garth library
// The exact implementation depends on the go-garth API
// Placeholder implementation
return &models.Activity{
ID: fmt.Sprintf("garmin-%v", gActivity),
Name: "Imported Activity",
Type: "running",
Date: time.Now(),
Duration: 30 * time.Minute,
Distance: 5000,
Metrics: models.ActivityMetrics{},
}
}
func (c *Client) convertActivityWithDetails(details interface{}) *models.Activity {
// Similar to convertActivity but with more detailed information
// Implementation depends on go-garth API structure
activity := c.convertActivity(details)
// Add detailed metrics here
return activity
}
Step 9: LLM Analysis Integration
internal/analysis/llm.go
package analysis
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/yourusername/fitness-tui/internal/tui/models"
)
type OpenRouterClient struct {
apiKey string
baseURL string
model string
client *http.Client
}
type OpenRouterRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type OpenRouterResponse struct {
Choices []Choice `json:"choices"`
Error *APIError `json:"error,omitempty"`
}
type Choice struct {
Message Message `json:"message"`
}
type APIError struct {
Message string `json:"message"`
Type string `json:"type"`
}
func NewOpenRouterClient(apiKey, baseURL, model string) *OpenRouterClient {
return &OpenRouterClient{
apiKey: apiKey,
baseURL: baseURL,
model: model,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *OpenRouterClient) AnalyzeActivity(ctx context.Context, activity *models.Activity, workoutContext string) (*models.Analysis, error) {
prompt := c.buildAnalysisPrompt(activity, workoutContext)
request := OpenRouterRequest{
Model: c.model,
Messages: []Message{
{
Role: "user",
Content: prompt,
},
},
MaxTokens: 1000,
}
requestBody, err := json.Marshal(request)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewBuffer(requestBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
var response OpenRouterResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if response.Error != nil {
return nil, fmt.Errorf("API error: %s", response.Error.Message)
}
if len(response.Choices) == 0 {
return nil, fmt.Errorf("no response generated")
}
content := response.Choices[0].Message.Content
insights := c.extractInsights(content)
analysis := &models.Analysis{
ActivityID: activity.ID,
WorkoutType: workoutContext,
GeneratedAt: time.Now(),
Content: content,
Insights: insights,
}
return analysis, nil
}
func (c *OpenRouterClient) buildAnalysisPrompt(activity *models.Activity, workoutContext string) string {
return fmt.Sprintf(`Analyze this fitness activity in the context of the intended workout:
INTENDED WORKOUT: %s
ACTIVITY DETAILS:
- Name: %s
- Type: %s
- Date: %s
- Duration: %s
- Distance: %s
- Average Heart Rate: %d bpm
- Average Pace: %s
- Elevation Gain: %.0fm
Please provide:
1. How well did this activity match the intended workout?
2. Performance analysis (pacing, heart rate zones, effort level)
3. Areas for improvement
4. Recovery recommendations
5. Any red flags or injury risk factors
Format your response in markdown with clear sections.`,
workoutContext,
activity.Name,
activity.Type,
activity.Date.Format("2006-01-02"),
activity.FormattedDuration(),
activity.FormattedDistance(),
activity.Metrics.AvgHeartRate,
activity.FormattedPace(),
activity.Metrics.ElevationGain,
)
}
func (c *OpenRouterClient) extractInsights(content string) []string {
// Simple insight extraction - look for bullet points or numbered lists
// This is a basic implementation - could be enhanced with NLP
insights := []string{}
lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") {
insight := strings.TrimPrefix(strings.TrimPrefix(line, "- "), "* ")
if len(insight) > 10 { // Filter out very short lines
insights = append(insights, insight)
}
}
}
return insights
}
internal/analysis/cache.go
package analysis
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/yourusername/fitness-tui/internal/tui/models"
)
type AnalysisCache struct {
dataDir string
}
func NewAnalysisCache(dataDir string) *AnalysisCache {
analysisDir := filepath.Join(dataDir, "analysis")
os.MkdirAll(analysisDir, 0755)
return &AnalysisCache{
dataDir: dataDir,
}
}
func (c *AnalysisCache) Save(analysis *models.Analysis) error {
// Save as markdown file for easy reading
mdPath := c.getMarkdownPath(analysis.ActivityID, analysis.WorkoutType)
if err := os.WriteFile(mdPath, []byte(analysis.Content), 0644); err != nil {
return fmt.Errorf("failed to save analysis markdown: %w", err)
}
// Save metadata as JSON
metaPath := c.getMetadataPath(analysis.ActivityID, analysis.WorkoutType)
metadata := struct {
ActivityID string `json:"activity_id"`
WorkoutType string `json:"workout_type"`
GeneratedAt time.Time `json:"generated_at"`
Insights []string `json:"insights"`
MarkdownPath string `json:"markdown_path"`
}{
ActivityID: analysis.ActivityID,
WorkoutType: analysis.WorkoutType,
GeneratedAt: analysis.GeneratedAt,
Insights: analysis.Insights,
MarkdownPath: mdPath,
}
data, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal analysis metadata: %w", err)
}
if err := os.WriteFile(metaPath, data, 0644); err != nil {
return fmt.Errorf("failed to save analysis metadata: %w", err)
}
analysis.FilePath = mdPath
return nil
}
func (c *AnalysisCache) Load(activityID, workoutType string) (*models.Analysis, error) {
metaPath := c.getMetadataPath(activityID, workoutType)
if _, err := os.Stat(metaPath); os.IsNotExist(err) {
return nil, nil // No cached analysis
}
data, err := os.ReadFile(metaPath)
if err != nil {
return nil, fmt.Errorf("failed to read analysis metadata: %w", err)
}
var metadata struct {
ActivityID string `json:"activity_id"`
WorkoutType string `json:"workout_type"`
GeneratedAt time.Time `json:"generated_at"`
Insights []string `json:"insights"`
MarkdownPath string `json:"markdown_path"`
}
if err := json.Unmarshal(data, &metadata); err != nil {
return nil, fmt.Errorf("failed to unmarshal analysis metadata: %w", err)
}
// Load markdown content
content, err := os.ReadFile(metadata.MarkdownPath)
if err != nil {
return nil, fmt.Errorf("failed to read analysis content: %w", err)
}
analysis := &models.Analysis{
ActivityID: metadata.ActivityID,
WorkoutType: metadata.WorkoutType,
GeneratedAt: metadata.GeneratedAt,
Content: string(content),
Insights: metadata.Insights,
FilePath: metadata.MarkdownPath,
}
return analysis, nil
}
func (c *AnalysisCache) getMarkdownPath(activityID, workoutType string) string {
filename := fmt.Sprintf("%s-%s-analysis.md", activityID, sanitizeFilename(workoutType))
return filepath.Join(c.dataDir, "analysis", filename)
}
func (c *AnalysisCache) getMetadataPath(activityID, workoutType string) string {
filename := fmt.Sprintf("%s-%s-meta.json", activityID, sanitizeFilename(workoutType))
return filepath.Join(c.dataDir, "analysis", filename)
}
func sanitizeFilename(name string) string {
// Replace invalid filename characters
replacer := strings.NewReplacer(
"/", "-", "\\", "-", ":", "-", "*", "-",
"?", "-", "\"", "-", "<", "-", ">", "-",
"|", "-", " ", "-",
)
return replacer.Replace(name)
}
Step 10: Development Configuration
.air.toml (for hot reload during development)
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ./cmd"
delay = 0
exclude_dir = ["assets", "tmp", "vendor", "testdata", "dist"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_root = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true
examples/sample-config.yaml
garmin:
username: "your-garmin-username"
password: "your-garmin-password"
openrouter:
api_key: "your-openrouter-api-key"
model: "deepseek/deepseek-r1-05028:free"
base_url: "https://openrouter.ai/api/v1"
storage:
data_dir: "~/.fitness-tui"
Deployment
Building for Release
.goreleaser.yaml
project_name: fitness-tui
before:
hooks:
- go mod tidy
- go generate ./...
builds:
- env:
- CGO_ENABLED=0
main: ./cmd
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
archives:
- format: tar.gz
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
format_overrides:
- goos: windows
format: zip
checksum:
name_template: 'checksums.txt'
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
release:
github:
owner: yourusername
name: fitness-tui
Installation Script
install.sh
#!/bin/bash
set -e
# Fitness TUI Installation Script
REPO="yourusername/fitness-tui"
INSTALL_DIR="/usr/local/bin"
# Detect OS and architecture
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case $ARCH in
x86_64) ARCH="x86_64" ;;
arm64|aarch64) ARCH="arm64" ;;
*) echo "Unsupported architecture: $ARCH" && exit 1 ;;
esac
echo "Installing Fitness TUI for $OS-$ARCH..."
# Get latest release
LATEST=$(curl -s "https://api.github.com/repos/$REPO/releases/latest" | grep -o '"tag_name": "[^"]*' | cut -d'"' -f4)
if [ -z "$LATEST" ]; then
echo "Failed to get latest release"
exit 1
fi
echo "Latest version: $LATEST"
# Download URL
FILENAME="fitness-tui_${OS^}_${ARCH}.tar.gz"
if [ "$OS" = "windows" ]; then
FILENAME="fitness-tui_Windows_${ARCH}.zip"
fi
URL="https://github.com/$REPO/releases/download/$LATEST/$FILENAME"
echo "Downloading $URL..."
curl -L "$URL" -o "/tmp/$FILENAME"
# Extract and install
cd /tmp
if [ "$OS" = "windows" ]; then
unzip -q "$FILENAME"
else
tar -xzf "$FILENAME"
fi
# Move to install directory
sudo mv fitness-tui "$INSTALL_DIR/"
sudo chmod +x "$INSTALL_DIR/fitness-tui"
echo "Fitness TUI installed successfully to $INSTALL_DIR/fitness-tui"
echo "Run 'fitness-tui' to get started!"
# Cleanup
rm -f "/tmp/$FILENAME"
Development Workflow
Daily Development Commands
# Start development mode
make dev
# Run tests
make test
# Run tests with coverage
make test-coverage
# Format code
make fmt
# Lint code
make lint
# Build application
make build
# Run the built application
./fitness-tui
Git Workflow
# Feature development
git checkout -b feature/activity-analysis
# ... make changes ...
make fmt lint test
git add .
git commit -m "feat: add activity analysis with LLM integration"
git push origin feature/activity-analysis
# Create PR
# Release preparation
git checkout main
git tag v1.0.0
git push origin v1.0.0
# GitHub Actions will build and release
Next Steps
-
Phase 1 Implementation: Start with the core foundation
- Set up project structure
- Implement configuration system
- Create basic TUI with bubbletea
- Add file storage for activities
-
Testing: Write tests as you implement each component
- Unit tests for business logic
- Integration tests for storage
- TUI interaction tests
-
Documentation:
- Add godoc comments to all public functions
- Create user documentation
- Add examples and troubleshooting guides
-
Performance:
- Profile the application
- Optimize chart rendering
- Add caching where appropriate
This implementation guide provides a complete roadmap for building the fitness TUI application. Each step includes detailed code examples and follows Go best practices for maintainable, testable code.