This commit is contained in:
2025-09-13 18:19:50 -07:00
parent adc0dc8839
commit 0a3af79cc9
14 changed files with 1557 additions and 662 deletions

View File

@@ -29,7 +29,7 @@ func main() {
syncCmd := &cobra.Command{
Use: "sync",
Short: "Sync activities from Garmin Connect",
Short: "Sync activities and files from Garmin Connect",
Run: func(cmd *cobra.Command, args []string) {
logger := &garmin.CLILogger{}
logger.Infof("Starting sync process...")
@@ -43,29 +43,14 @@ func main() {
activityStorage := storage.NewActivityStorage(cfg.StoragePath)
garminClient := garmin.NewClient(cfg.Garmin.Username, cfg.Garmin.Password, cfg.StoragePath)
// Authenticate
if err := garminClient.Connect(logger); err != nil {
logger.Errorf("Authentication failed: %v", err)
os.Exit(1)
}
// Get activities
activities, err := garminClient.GetActivities(context.Background(), 50, logger)
// Use the new Sync method that handles file downloads
count, err := garminClient.Sync(context.Background(), activityStorage, logger)
if err != nil {
logger.Errorf("Failed to fetch activities: %v", err)
logger.Errorf("Sync failed: %v", err)
os.Exit(1)
}
// Save activities
for i, activity := range activities {
if err := activityStorage.Save(activity); err != nil {
logger.Errorf("Failed to save activity %s: %v", activity.ID, err)
} else {
logger.Infof("[%d/%d] Saved activity: %s", i+1, len(activities), activity.Name)
}
}
logger.Infof("Successfully synced %d activities", len(activities))
logger.Infof("Successfully synced %d activities with files", count)
},
}

Binary file not shown.

View File

@@ -15,6 +15,8 @@ import (
type GarminClient interface {
Connect(logger Logger) error
GetActivities(ctx context.Context, limit int, logger Logger) ([]*models.Activity, error)
GetAllActivities(ctx context.Context, logger Logger) ([]*models.Activity, error)
DownloadActivityFile(ctx context.Context, activityID string, format string, logger Logger) ([]byte, error)
}
type Client struct {
@@ -83,7 +85,7 @@ func (c *Client) GetActivities(ctx context.Context, limit int, logger Logger) ([
}
// Get activities from Garmin API
garthActivities, err := c.garthClient.GetActivities(limit)
garthActivities, err := c.garthClient.GetActivities(limit, 0)
if err != nil {
logger.Errorf("Failed to fetch activities: %v", err)
return nil, err
@@ -138,3 +140,135 @@ func (c *Client) GetActivities(ctx context.Context, limit int, logger Logger) ([
logger.Infof("Successfully fetched %d activities", len(activities))
return activities, nil
}
func (c *Client) GetAllActivities(ctx context.Context, logger Logger) ([]*models.Activity, error) {
if logger == nil {
logger = &NoopLogger{}
}
logger.Infof("Fetching all activities from Garmin Connect")
if c.garthClient == nil {
if err := c.Connect(logger); err != nil {
return nil, err
}
}
var allActivities []*models.Activity
pageSize := 100
start := 0
timeout := 30 * time.Second
for {
logger.Infof("Fetching activities from offset %d", start)
// Create context with timeout for this page
pageCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
garthActivities, err := c.garthClient.GetActivities(pageSize, start)
if err != nil {
if pageCtx.Err() == context.DeadlineExceeded {
logger.Errorf("Timeout fetching activities from offset %d", start)
} else {
logger.Errorf("Failed to fetch activities: %v", err)
}
return nil, err
}
if len(garthActivities) == 0 {
break
}
logger.Infof("Fetched %d activities from offset %d", len(garthActivities), start)
for _, ga := range garthActivities {
// Skip activities with invalid start time
if ga.StartTimeGMT.IsZero() {
logger.Warnf("Activity %d has invalid start time", ga.ActivityID)
continue
}
activity := &models.Activity{
ID: fmt.Sprintf("%d", ga.ActivityID),
Name: ga.ActivityName,
Type: ga.ActivityType.TypeKey,
Date: ga.StartTimeGMT.Time,
Distance: ga.Distance,
Duration: time.Duration(ga.Duration) * time.Second,
Elevation: ga.ElevationGain,
Calories: int(ga.Calories),
}
// Populate metrics
if ga.AverageHR > 0 {
activity.Metrics.AvgHeartRate = int(ga.AverageHR)
}
if ga.MaxHR > 0 {
activity.Metrics.MaxHeartRate = int(ga.MaxHR)
}
if ga.AverageSpeed > 0 {
activity.Metrics.AvgSpeed = ga.AverageSpeed * 3.6
}
if ga.ElevationGain > 0 {
activity.Metrics.ElevationGain = ga.ElevationGain
}
if ga.ElevationLoss > 0 {
activity.Metrics.ElevationLoss = ga.ElevationLoss
}
if ga.AverageSpeed > 0 && ga.Distance > 0 {
activity.Metrics.AvgPace = (ga.Duration / ga.Distance) * 1000
}
allActivities = append(allActivities, activity)
}
// Break if we got fewer than page size
if len(garthActivities) < pageSize {
break
}
start += len(garthActivities)
// Increase timeout for next page in case of large datasets
timeout += 10 * time.Second
}
logger.Infof("Successfully fetched %d activities in total", len(allActivities))
return allActivities, nil
}
func (c *Client) DownloadActivityFile(ctx context.Context, activityID string, format string, logger Logger) ([]byte, error) {
if logger == nil {
logger = &NoopLogger{}
}
logger.Infof("Downloading %s file for activity %s", format, activityID)
if c.garthClient == nil {
if err := c.Connect(logger); err != nil {
return nil, err
}
}
// Construct download URL based on format
var path string
switch format {
case "gpx":
path = fmt.Sprintf("/download-service/export/gpx/activity/%s", activityID)
case "tcx":
path = fmt.Sprintf("/download-service/export/tcx/activity/%s", activityID)
case "fit":
path = fmt.Sprintf("/download-service/files/activity/%s", activityID)
default:
return nil, fmt.Errorf("unsupported file format: %s", format)
}
// Download file content
data, err := c.garthClient.Download(path)
if err != nil {
logger.Errorf("Failed to download %s file for activity %s: %v", format, activityID, err)
return nil, err
}
logger.Infof("Successfully downloaded %s file for activity %s (%d bytes)", format, activityID, len(data))
return data, nil
}

View File

@@ -160,12 +160,12 @@ func (c *Client) GetUserProfile() (*UserProfile, error) {
}
// GetActivities retrieves recent activities
func (c *Client) GetActivities(limit int) ([]types.Activity, error) {
func (c *Client) GetActivities(limit int, start int) ([]types.Activity, error) {
if limit <= 0 {
limit = 10
}
activitiesURL := fmt.Sprintf("https://connectapi.%s/activitylist-service/activities/search/activities?limit=%d&start=0", c.Domain, limit)
activitiesURL := fmt.Sprintf("https://connectapi.%s/activitylist-service/activities/search/activities?limit=%d&start=%d", c.Domain, limit, start)
req, err := http.NewRequest("GET", activitiesURL, nil)
if err != nil {

View File

@@ -2,20 +2,107 @@ package garmin
import (
"context"
"time"
"github.com/sstent/fitness-tui/internal/storage"
)
// Sync performs the complete synchronization process
func (c *Client) Sync(ctx context.Context, logger Logger) (int, error) {
func (c *Client) Sync(ctx context.Context, storage *storage.ActivityStorage, logger Logger) (int, error) {
// Create a context with timeout for the entire sync process
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
// Authenticate
logger.Infof("Authenticating with Garmin Connect...")
if err := c.Connect(logger); err != nil {
logger.Errorf("Authentication failed: %v", err)
return 0, err
}
logger.Infof("Authentication successful")
// Get activities
activities, err := c.GetActivities(ctx, 50, logger)
// Get all activities metadata
logger.Infof("Fetching activity metadata...")
activities, err := c.GetAllActivities(timeoutCtx, logger)
if err != nil {
logger.Errorf("Failed to fetch activities: %v", err)
return 0, err
}
logger.Infof("Found %d activities", len(activities))
// Download files for each activity
downloadedFiles := 0
for i, activity := range activities {
// Check if context has been cancelled
select {
case <-timeoutCtx.Done():
logger.Warnf("Sync cancelled due to timeout")
return downloadedFiles, timeoutCtx.Err()
default:
}
logger.Infof("Processing activity %d/%d: %s", i+1, len(activities), activity.Name)
// Only download if file doesn't exist
if activity.FilePath == "" {
logger.Infof("File missing for activity %s, attempting download...", activity.ID)
var data []byte
var format string
var err error
// First try FIT (preferred)
logger.Infof("Trying FIT download for %s...", activity.ID)
data, err = c.DownloadActivityFile(timeoutCtx, activity.ID, "fit", logger)
if err == nil {
format = "fit"
logger.Infof("FIT download successful for %s (%d bytes)", activity.ID, len(data))
} else {
logger.Warnf("FIT download failed for %s: %v", activity.ID, err)
// Fallback to GPX
logger.Infof("Trying GPX download for %s...", activity.ID)
data, err = c.DownloadActivityFile(timeoutCtx, activity.ID, "gpx", logger)
if err == nil {
format = "gpx"
logger.Infof("GPX download successful for %s (%d bytes)", activity.ID, len(data))
} else {
logger.Warnf("GPX download failed for %s: %v", activity.ID, err)
// Fallback to TCX
logger.Infof("Trying TCX download for %s...", activity.ID)
data, err = c.DownloadActivityFile(timeoutCtx, activity.ID, "tcx", logger)
if err != nil {
logger.Errorf("TCX download failed for %s: %v", activity.ID, err)
continue
}
format = "tcx"
logger.Infof("TCX download successful for %s (%d bytes)", activity.ID, len(data))
}
}
// Save file to storage
logger.Infof("Saving %s file for %s...", format, activity.ID)
filePath, err := storage.SaveActivityFile(activity, data, format)
if err != nil {
logger.Errorf("Failed to save activity file for %s: %v", activity.ID, err)
continue
}
logger.Infof("Saved file to %s", filePath)
// Update activity with file path
activity.FilePath = filePath
downloadedFiles++
} else {
logger.Infof("File already exists for %s: %s", activity.ID, activity.FilePath)
}
// Save updated activity metadata
logger.Infof("Saving metadata for %s...", activity.ID)
if err := storage.Save(activity); err != nil {
logger.Errorf("Failed to save activity metadata for %s: %v", activity.ID, err)
}
}
logger.Infof("Sync complete. Downloaded %d new files for %d activities", downloadedFiles, len(activities))
return len(activities), nil
}

View File

@@ -20,6 +20,10 @@ func NewActivityStorage(dataDir string) *ActivityStorage {
activitiesDir := filepath.Join(dataDir, "activities")
os.MkdirAll(activitiesDir, 0755)
// Create directory for activity files
filesDir := filepath.Join(dataDir, "activity_files")
os.MkdirAll(filesDir, 0755)
return &ActivityStorage{
dataDir: dataDir,
lockPath: filepath.Join(dataDir, "sync.lock"),
@@ -85,6 +89,20 @@ func (s *ActivityStorage) Save(activity *models.Activity) error {
return nil
}
// SaveActivityFile saves the activity file (GPX/TCX) and returns the relative path to the file
func (s *ActivityStorage) SaveActivityFile(activity *models.Activity, content []byte, format string) (string, error) {
filename := fmt.Sprintf("%s.%s", activity.ID, format)
targetPath := filepath.Join(s.dataDir, "activity_files", filename)
if err := os.WriteFile(targetPath, content, 0644); err != nil {
return "", fmt.Errorf("failed to write activity file: %w", err)
}
// Return the relative path within the dataDir
relativePath := filepath.Join("activity_files", filename)
return relativePath, nil
}
func (s *ActivityStorage) LoadAll() ([]*models.Activity, error) {
activitiesDir := filepath.Join(s.dataDir, "activities")

View File

@@ -1,3 +1,4 @@
// internal/tui/app.go
package tui
import (
@@ -17,6 +18,7 @@ type App struct {
activityList *screens.ActivityList // Persistent activity list
width int // Track window width
height int // Track window height
screenStack []tea.Model // Screen navigation stack
}
func NewApp(activityStorage *storage.ActivityStorage, garminClient *garmin.Client, logger garmin.Logger) *App {
@@ -24,16 +26,20 @@ func NewApp(activityStorage *storage.ActivityStorage, garminClient *garmin.Clien
logger = &garmin.NoopLogger{}
}
// Initialize with the activity list screen as the default
// Initialize with a placeholder screen - actual size will be set by WindowSizeMsg
activityList := screens.NewActivityList(activityStorage, garminClient)
return &App{
app := &App{
currentModel: activityList,
activityStorage: activityStorage,
garminClient: garminClient,
logger: logger,
activityList: activityList, // Store persistent reference
screenStack: []tea.Model{activityList},
width: 80, // Default width
height: 24, // Default height
}
return app
}
func (a *App) Init() tea.Cmd {
@@ -43,44 +49,51 @@ func (a *App) Init() tea.Cmd {
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
// Store window size and forward to current model
a.width = msg.Width
a.height = msg.Height
updatedModel, cmd := a.currentModel.Update(msg)
a.currentModel = updatedModel
return a, cmd
// Only update if size actually changed
if a.width != msg.Width || a.height != msg.Height {
a.width = msg.Width
a.height = msg.Height
updatedModel, cmd := a.currentModel.Update(msg)
a.currentModel = updatedModel
a.updateStackTop(updatedModel)
return a, cmd
}
return a, nil
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
// Only quit if we're at the top level (activity list)
if _, ok := a.currentModel.(*screens.ActivityList); ok {
return a, tea.Quit
case "ctrl+c":
// Force quit on Ctrl+C
return a, tea.Quit
case "q":
// Handle quit - go back in stack or quit if at root
if len(a.screenStack) <= 1 {
// At root level, quit
if _, ok := a.currentModel.(*screens.ActivityList); ok {
return a, tea.Quit
}
} else {
// Go back to previous screen
return a, a.goBack()
}
}
case screens.ActivitySelectedMsg:
a.logger.Debugf("App.Update() - Received ActivitySelectedMsg for: %s", msg.Activity.Name)
// For now, use empty analysis - we'll implement analysis caching later
// Create new activity detail screen
detail := screens.NewActivityDetail(msg.Activity, "", a.logger)
a.currentModel = detail
a.pushScreen(detail)
return a, detail.Init()
case screens.BackToListMsg:
a.logger.Debugf("App.Update() - Received BackToListMsg")
// Return to existing activity list instead of creating new
a.currentModel = a.activityList
// Send current window size to ensure proper rendering
return a, func() tea.Msg {
return tea.WindowSizeMsg{Width: a.width, Height: a.height}
}
return a, a.goBack()
}
// Delegate to the current model
updatedModel, cmd := a.currentModel.Update(msg)
a.currentModel = updatedModel
// Update activity list reference if needed
if activityList, ok := updatedModel.(*screens.ActivityList); ok {
a.activityList = activityList
}
a.updateStackTop(updatedModel)
return a, cmd
}
@@ -90,9 +103,48 @@ func (a *App) View() string {
}
func (a *App) Run() error {
p := tea.NewProgram(a)
// Use alt screen for better TUI experience
p := tea.NewProgram(a, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
return fmt.Errorf("failed to run application: %w", err)
}
return nil
}
// pushScreen adds a new screen to the stack and makes it current
func (a *App) pushScreen(model tea.Model) {
a.screenStack = append(a.screenStack, model)
a.currentModel = model
}
// goBack removes the current screen from stack and returns to previous
func (a *App) goBack() tea.Cmd {
if len(a.screenStack) <= 1 {
// Already at root, can't go back further
return nil
}
// Remove current screen
a.screenStack = a.screenStack[:len(a.screenStack)-1]
// Set previous screen as current
a.currentModel = a.screenStack[len(a.screenStack)-1]
// Update the model with current window size
var cmd tea.Cmd
a.currentModel, cmd = a.currentModel.Update(tea.WindowSizeMsg{Width: a.width, Height: a.height})
a.updateStackTop(a.currentModel)
return cmd
}
// updateStackTop updates the top of the stack with the current model
func (a *App) updateStackTop(model tea.Model) {
if len(a.screenStack) > 0 {
a.screenStack[len(a.screenStack)-1] = model
}
// Update activity list reference if needed
if activityList, ok := model.(*screens.ActivityList); ok {
a.activityList = activityList
}
}

View File

@@ -1,3 +1,4 @@
// internal/tui/components/chart.go
package components
import (
@@ -22,88 +23,267 @@ const (
var blockChars = []string{BlockEmpty, Block1, Block2, Block3, Block4, Block5, Block6, Block7, Block8}
type Chart struct {
Width int
Height int
Data []float64
Title string
style lipgloss.Style
Width int
Height int
Data []float64
Title string
Color lipgloss.Color
ShowAxes bool
ShowGrid bool
style lipgloss.Style
}
func NewChart(data []float64, width, height int, title string) *Chart {
return &Chart{
Data: data,
Width: width,
Height: height,
Title: title,
style: lipgloss.NewStyle().Padding(0, 1),
Data: data,
Width: width,
Height: height,
Title: title,
Color: lipgloss.Color("#00D4FF"), // Default bright cyan
ShowAxes: true,
ShowGrid: false,
style: lipgloss.NewStyle().Padding(0, 1),
}
}
func NewColoredChart(data []float64, width, height int, title string, color lipgloss.Color) *Chart {
return &Chart{
Data: data,
Width: width,
Height: height,
Title: title,
Color: color,
ShowAxes: true,
ShowGrid: false,
style: lipgloss.NewStyle().Padding(0, 1),
}
}
func (c *Chart) WithColor(color lipgloss.Color) *Chart {
c.Color = color
return c
}
func (c *Chart) WithGrid(showGrid bool) *Chart {
c.ShowGrid = showGrid
return c
}
func (c *Chart) WithAxes(showAxes bool) *Chart {
c.ShowAxes = showAxes
return c
}
func (c *Chart) View() string {
if len(c.Data) == 0 {
return c.style.Render("No data available")
return c.style.Render(lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
Italic(true).
Render("No data available"))
}
min, max := findMinMax(c.Data)
sampled := sampleData(c.Data, c.Width)
sampled := sampleData(c.Data, c.Width-8) // Account for Y-axis labels
// Create chart rows from top to bottom
rows := make([]string, c.Height)
for i := range rows {
rows[i] = strings.Repeat(" ", c.Width)
var content strings.Builder
// Add title with color
if c.Title != "" {
titleStyle := lipgloss.NewStyle().
Foreground(c.Color).
Bold(true).
MarginBottom(1)
content.WriteString(titleStyle.Render(c.Title))
content.WriteString("\n")
}
// Fill chart based on values
for i, value := range sampled {
if max == min {
// Handle case where all values are equal
level := c.Height / 2
if level >= c.Height {
level = c.Height - 1
// Create the chart
chartContent := c.renderChart(sampled, min, max)
content.WriteString(chartContent)
return c.style.Render(content.String())
}
func (c *Chart) renderChart(data []float64, min, max float64) string {
if c.Height < 3 {
// Fallback for very small charts
return c.renderMiniChart(data, min, max)
}
var content strings.Builder
chartHeight := c.Height - 1 // Reserve space for X-axis
chartWidth := len(data)
// Create chart grid
rows := make([][]rune, chartHeight)
for i := range rows {
rows[i] = make([]rune, chartWidth)
for j := range rows[i] {
if c.ShowGrid && (i%2 == 0 || j%5 == 0) {
rows[i][j] = '·'
} else {
rows[i][j] = ' '
}
rows[level] = replaceAtIndex(rows[level], blockChars[7], i)
}
}
// Plot data points
for i, value := range data {
if i >= chartWidth {
break
}
var level int
if max == min {
level = chartHeight / 2
} else {
normalized := (value - min) / (max - min)
level := int(normalized * float64(c.Height-1))
if level >= c.Height {
level = c.Height - 1
}
if level < 0 {
level = 0
}
// Draw from bottom up
rowIndex := c.Height - 1 - level
rows[rowIndex] = replaceAtIndex(rows[rowIndex], blockChars[7], i)
level = int(normalized * float64(chartHeight-1))
}
if level >= chartHeight {
level = chartHeight - 1
}
if level < 0 {
level = 0
}
// Draw from bottom up
rowIndex := chartHeight - 1 - level
// Use different block characters based on the data density
var blockIndex int
if max == min {
blockIndex = 3 // Use Block4 (▄) for constant data
} else {
normalized := (value - min) / (max - min)
blockIndex = int(normalized * 7)
if blockIndex > 7 {
blockIndex = 7
}
if blockIndex < 0 {
blockIndex = 0
}
}
rows[rowIndex][i] = []rune(blockChars[blockIndex+1])[0]
}
// Add Y-axis labels
chartWithLabels := ""
if c.Height > 3 {
chartWithLabels += fmt.Sprintf("%5.1f ┤\n", max)
for i := 1; i < c.Height-1; i++ {
chartWithLabels += " │ " + rows[i] + "\n"
// Render chart with Y-axis labels
if c.ShowAxes {
for i := 0; i < chartHeight; i++ {
// Y-axis label
yValue := max - (float64(i)/float64(chartHeight-1))*(max-min)
label := fmt.Sprintf("%6.1f", yValue)
// Y-axis line and data
line := fmt.Sprintf("%s │", label)
// Color the chart data
chartLine := string(rows[i])
coloredLine := lipgloss.NewStyle().
Foreground(c.Color).
Render(chartLine)
content.WriteString(line + coloredLine + "\n")
}
chartWithLabels += fmt.Sprintf("%5.1f ┤ %s", min, rows[c.Height-1])
// X-axis
content.WriteString(" └")
content.WriteString(strings.Repeat("─", chartWidth))
content.WriteString("\n")
// X-axis labels (show first, middle, last)
spacing := chartWidth/2 - 1
if spacing < 0 {
spacing = 0
}
xAxisLabels := fmt.Sprintf(" %s%s%s",
"0",
strings.Repeat(" ", spacing),
fmt.Sprintf("%d", len(c.Data)))
if chartWidth > 10 {
midPoint := chartWidth / 2
leftSpacing := midPoint - 2
rightSpacing := midPoint - 2
if leftSpacing < 0 {
leftSpacing = 0
}
if rightSpacing < 0 {
rightSpacing = 0
}
xAxisLabels = fmt.Sprintf(" 0%s%d%s%d",
strings.Repeat(" ", leftSpacing),
len(c.Data)/2,
strings.Repeat(" ", rightSpacing),
len(c.Data))
}
content.WriteString(lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
Render(xAxisLabels))
} else {
// Fallback for small heights
for _, row := range rows {
chartWithLabels += row + "\n"
// Simple chart without axes
for i := 0; i < chartHeight; i++ {
chartLine := string(rows[i])
coloredLine := lipgloss.NewStyle().
Foreground(c.Color).
Render(chartLine)
content.WriteString(coloredLine + "\n")
}
}
// Add X-axis title
return c.style.Render(fmt.Sprintf("%s\n%s", c.Title, chartWithLabels))
return content.String()
}
// Helper function to replace character at index
func replaceAtIndex(in string, r string, i int) string {
out := []rune(in)
out[i] = []rune(r)[0]
return string(out)
func (c *Chart) renderMiniChart(data []float64, min, max float64) string {
var result strings.Builder
for _, value := range data {
var blockIndex int
if max == min {
blockIndex = 4 // Middle block
} else {
normalized := (value - min) / (max - min)
blockIndex = int(normalized * 7)
if blockIndex > 7 {
blockIndex = 7
}
if blockIndex < 0 {
blockIndex = 0
}
}
block := lipgloss.NewStyle().
Foreground(c.Color).
Render(blockChars[blockIndex+1])
result.WriteString(block)
}
return result.String()
}
// Helper functions for min/max integers
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// Helper functions remain the same
func findMinMax(data []float64) (float64, float64) {
if len(data) == 0 {
return 0, 0
}
min, max := data[0], data[0]
for _, v := range data {
if v < min {
@@ -117,7 +297,7 @@ func findMinMax(data []float64) (float64, float64) {
}
func sampleData(data []float64, targetLength int) []float64 {
if len(data) <= targetLength {
if len(data) <= targetLength || targetLength <= 0 {
return data
}
@@ -133,3 +313,35 @@ func sampleData(data []float64, targetLength int) []float64 {
}
return sampled
}
// Sparkline creates a simple inline chart
func (c *Chart) Sparkline() string {
if len(c.Data) == 0 {
return lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
Render("─────")
}
minVal, maxVal := findMinMax(c.Data)
var result strings.Builder
for _, value := range c.Data {
var blockIndex int
if maxVal == minVal {
blockIndex = 4
} else {
normalized := (value - minVal) / (maxVal - minVal)
blockIndex = int(normalized * 7)
if blockIndex > 7 {
blockIndex = 7
}
}
block := lipgloss.NewStyle().
Foreground(c.Color).
Render(blockChars[blockIndex+1])
result.WriteString(block)
}
return result.String()
}

View File

@@ -0,0 +1,298 @@
// internal/tui/layout/layout.go
package layout
import (
"github.com/charmbracelet/lipgloss"
)
// Color palette with bright, distinctive colors
var (
// Primary colors
PrimaryBlue = lipgloss.Color("#00D4FF") // Bright cyan
PrimaryCyan = lipgloss.Color("#00FFFF") // Pure cyan
PrimaryPurple = lipgloss.Color("#B300FF") // Bright purple
PrimaryGreen = lipgloss.Color("#00FF88") // Bright green
PrimaryOrange = lipgloss.Color("#FF8800") // Bright orange
PrimaryPink = lipgloss.Color("#FF0088") // Bright pink
PrimaryYellow = lipgloss.Color("#FFD700") // Gold
// Background colors
DarkBG = lipgloss.Color("#1a1a1a")
LightBG = lipgloss.Color("#2a2a2a")
CardBG = lipgloss.Color("#333333")
// Text colors
WhiteText = lipgloss.Color("#FFFFFF")
LightText = lipgloss.Color("#CCCCCC")
MutedText = lipgloss.Color("#888888")
)
// Layout represents the main layout structure
type Layout struct {
Width int
Height int
}
// NewLayout creates a new layout with given dimensions
func NewLayout(width, height int) *Layout {
return &Layout{
Width: width,
Height: height,
}
}
// MainContainer creates the primary container style
func (l *Layout) MainContainer() lipgloss.Style {
return lipgloss.NewStyle().
Width(l.Width).
Height(l.Height).
Background(DarkBG).
Padding(1)
}
// HeaderPanel creates a header panel with title and navigation
func (l *Layout) HeaderPanel(title string, breadcrumb string) string {
headerStyle := lipgloss.NewStyle().
Width(l.Width-4).
Height(3).
Background(PrimaryBlue).
Foreground(WhiteText).
Bold(true).
Padding(1, 2).
MarginBottom(1)
titleStyle := lipgloss.NewStyle().
Foreground(WhiteText).
Bold(true)
breadcrumbStyle := lipgloss.NewStyle().
Foreground(LightText).
Italic(true)
content := lipgloss.JoinVertical(lipgloss.Left,
titleStyle.Render(title),
breadcrumbStyle.Render(breadcrumb),
)
return headerStyle.Render(content)
}
// SidePanel creates a colorful side panel
func (l *Layout) SidePanel(title string, content string, color lipgloss.Color, width int) string {
panelStyle := lipgloss.NewStyle().
Width(width).
Height(l.Height-8).
Background(CardBG).
Border(lipgloss.RoundedBorder()).
BorderForeground(color).
Padding(1, 2).
MarginRight(1)
titleStyle := lipgloss.NewStyle().
Foreground(color).
Bold(true).
MarginBottom(1)
contentStyle := lipgloss.NewStyle().
Foreground(LightText).
Width(width - 6)
panelContent := lipgloss.JoinVertical(lipgloss.Left,
titleStyle.Render(title),
contentStyle.Render(content),
)
return panelStyle.Render(panelContent)
}
// MainPanel creates the main content panel
func (l *Layout) MainPanel(content string, remainingWidth int) string {
panelStyle := lipgloss.NewStyle().
Width(remainingWidth).
Height(l.Height-8).
Background(CardBG).
Border(lipgloss.RoundedBorder()).
BorderForeground(PrimaryPurple).
Padding(1, 2)
contentStyle := lipgloss.NewStyle().
Foreground(LightText).
Width(remainingWidth - 6)
return panelStyle.Render(contentStyle.Render(content))
}
// StatCard creates a statistics card with bright colors
func (l *Layout) StatCard(title string, value string, color lipgloss.Color, width int) string {
cardStyle := lipgloss.NewStyle().
Width(width).
Height(4).
Background(CardBG).
Border(lipgloss.RoundedBorder()).
BorderForeground(color).
Padding(1).
MarginRight(1).
MarginBottom(1)
titleStyle := lipgloss.NewStyle().
Foreground(MutedText).
Bold(false).
Align(lipgloss.Center)
valueStyle := lipgloss.NewStyle().
Foreground(color).
Bold(true).
Align(lipgloss.Center)
content := lipgloss.JoinVertical(lipgloss.Center,
titleStyle.Render(title),
valueStyle.Render(value),
)
return cardStyle.Render(content)
}
// NavigationBar creates a bottom navigation bar
func (l *Layout) NavigationBar(items []NavItem, activeIndex int) string {
navStyle := lipgloss.NewStyle().
Width(l.Width-4).
Height(2).
Background(LightBG).
Border(lipgloss.RoundedBorder()).
BorderForeground(PrimaryGreen).
Padding(0, 2).
MarginTop(1)
var navItems []string
for i, item := range items {
if i == activeIndex {
activeStyle := lipgloss.NewStyle().
Foreground(PrimaryGreen).
Bold(true).
Background(DarkBG).
Padding(0, 2)
navItems = append(navItems, activeStyle.Render(item.Label))
} else {
inactiveStyle := lipgloss.NewStyle().
Foreground(MutedText).
Padding(0, 2)
navItems = append(navItems, inactiveStyle.Render(item.Label))
}
}
content := lipgloss.JoinHorizontal(lipgloss.Center, navItems...)
return navStyle.Render(content)
}
// ActivityCard creates a card for activity items
func (l *Layout) ActivityCard(name, date, duration, distance string, isSelected bool) string {
width := (l.Width - 12) / 2 // Two columns
var cardStyle lipgloss.Style
if isSelected {
cardStyle = lipgloss.NewStyle().
Width(width).
Height(6).
Background(CardBG).
Border(lipgloss.RoundedBorder()).
BorderForeground(PrimaryOrange).
Padding(1, 2).
MarginRight(1).
MarginBottom(1)
} else {
cardStyle = lipgloss.NewStyle().
Width(width).
Height(6).
Background(CardBG).
Border(lipgloss.RoundedBorder()).
BorderForeground(MutedText).
Padding(1, 2).
MarginRight(1).
MarginBottom(1)
}
titleStyle := lipgloss.NewStyle().
Foreground(WhiteText).
Bold(true).
MarginBottom(1)
metaStyle := lipgloss.NewStyle().
Foreground(LightText)
dateStyle := lipgloss.NewStyle().
Foreground(PrimaryBlue)
content := lipgloss.JoinVertical(lipgloss.Left,
titleStyle.Render(name),
dateStyle.Render(date),
metaStyle.Render(duration+" • "+distance),
)
return cardStyle.Render(content)
}
// ChartPanel creates a panel for charts
func (l *Layout) ChartPanel(title string, chartContent string, color lipgloss.Color) string {
panelStyle := lipgloss.NewStyle().
Width(l.Width-8).
Background(CardBG).
Border(lipgloss.RoundedBorder()).
BorderForeground(color).
Padding(1, 2).
MarginBottom(1)
titleStyle := lipgloss.NewStyle().
Foreground(color).
Bold(true).
MarginBottom(1)
content := lipgloss.JoinVertical(lipgloss.Left,
titleStyle.Render(title),
chartContent,
)
return panelStyle.Render(content)
}
// HelpText creates styled help text
func (l *Layout) HelpText(text string) string {
return lipgloss.NewStyle().
Foreground(MutedText).
Italic(true).
Align(lipgloss.Center).
Width(l.Width - 4).
Render(text)
}
// NavItem represents a navigation item
type NavItem struct {
Label string
Key string
}
// TwoColumnLayout creates a two-column layout
func (l *Layout) TwoColumnLayout(leftContent, rightContent string, leftWidth int) string {
rightWidth := l.Width - leftWidth - 6
leftPanel := lipgloss.NewStyle().
Width(leftWidth).
Height(l.Height-8).
Background(CardBG).
Border(lipgloss.RoundedBorder()).
BorderForeground(PrimaryPink).
Padding(1, 2).
MarginRight(2)
rightPanel := lipgloss.NewStyle().
Width(rightWidth).
Height(l.Height-8).
Background(CardBG).
Border(lipgloss.RoundedBorder()).
BorderForeground(PrimaryYellow).
Padding(1, 2)
return lipgloss.JoinHorizontal(lipgloss.Top,
leftPanel.Render(leftContent),
rightPanel.Render(rightContent),
)
}

View File

@@ -16,6 +16,7 @@ type Activity struct {
Elevation float64
Calories int // in kilocalories
Metrics ActivityMetrics
FilePath string // Path to original activity file (e.g., GPX/FIT)
}
type ActivityMetrics struct {

View File

@@ -1,3 +1,4 @@
// internal/tui/screens/activity_detail.go
package screens
import (
@@ -10,6 +11,7 @@ import (
"github.com/sstent/fitness-tui/internal/garmin"
"github.com/sstent/fitness-tui/internal/tui/components"
"github.com/sstent/fitness-tui/internal/tui/layout"
"github.com/sstent/fitness-tui/internal/tui/models"
)
@@ -19,22 +21,13 @@ type ActivityDetail struct {
activity *models.Activity
analysis string
viewport viewport.Model
width int
height int
styles *Styles
layout *layout.Layout
hrChart *components.Chart
elevationChart *components.Chart
logger garmin.Logger
ready bool // Tracks if viewport has been initialized
}
type Styles struct {
Title lipgloss.Style
Subtitle lipgloss.Style
StatName lipgloss.Style
StatValue lipgloss.Style
Analysis lipgloss.Style
Viewport lipgloss.Style
ready bool
currentTab int // 0: Overview, 1: Charts, 2: Analysis
tabNames []string
}
func NewActivityDetail(activity *models.Activity, analysis string, logger garmin.Logger) *ActivityDetail {
@@ -42,48 +35,62 @@ func NewActivityDetail(activity *models.Activity, analysis string, logger garmin
logger = &garmin.NoopLogger{}
}
styles := &Styles{
Title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("5")),
Subtitle: lipgloss.NewStyle().Foreground(lipgloss.Color("8")).MarginTop(1),
StatName: lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
StatValue: lipgloss.NewStyle().Foreground(lipgloss.Color("15")),
Analysis: lipgloss.NewStyle().MarginTop(1),
Viewport: lipgloss.NewStyle().Padding(0, 1),
}
vp := viewport.New(80, 20) // Default dimensions
vp := viewport.New(80, 20)
ad := &ActivityDetail{
activity: activity,
analysis: analysis,
viewport: vp,
styles: styles,
layout: layout.NewLayout(80, 24),
logger: logger,
hrChart: components.NewChart(activity.Metrics.HeartRateData, 40, 4, "Heart Rate (bpm)"),
elevationChart: components.NewChart(activity.Metrics.ElevationData, 40, 4, "Elevation (m)"),
tabNames: []string{"Overview", "Charts", "Analysis"},
}
ad.setContent()
return ad
}
func (m *ActivityDetail) Init() tea.Cmd {
// Request window size to get proper dimensions
return tea.Batch(
func() tea.Msg { return tea.WindowSizeMsg{Width: 80, Height: 24} },
)
return func() tea.Msg {
return tea.WindowSizeMsg{Width: 80, Height: 24}
}
}
func (m *ActivityDetail) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.viewport = viewport.New(msg.Width, msg.Height-2)
m.layout = layout.NewLayout(msg.Width, msg.Height)
// Calculate viewport height dynamically
headerHeight := 3
tabHeight := 3
navHeight := 2
helpHeight := 1
padding := 2
viewportHeight := msg.Height - headerHeight - tabHeight - navHeight - helpHeight - padding
m.viewport = viewport.New(msg.Width-4, viewportHeight)
m.ready = true
m.setContent()
case tea.KeyMsg:
switch msg.String() {
case "esc", "b", "q": // Add 'q' key for quitting/going back
case "esc", "b", "q":
return m, func() tea.Msg { return BackToListMsg{} }
case "tab", "right", "l":
m.currentTab = (m.currentTab + 1) % len(m.tabNames)
m.setContent()
case "shift+tab", "left", "h":
m.currentTab = (m.currentTab - 1 + len(m.tabNames)) % len(m.tabNames)
m.setContent()
case "1":
m.currentTab = 0
m.setContent()
case "2":
m.currentTab = 1
m.setContent()
case "3":
m.currentTab = 2
m.setContent()
}
}
@@ -97,169 +104,319 @@ func (m *ActivityDetail) View() string {
return "Loading activity details..."
}
instructions := lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Render("esc back • q quit")
var content strings.Builder
return lipgloss.JoinVertical(lipgloss.Left,
m.viewport.View(),
instructions,
)
// Header with activity name
breadcrumb := "Home > Activities > " + m.activity.Name
content.WriteString(m.layout.HeaderPanel(m.activity.Name, breadcrumb))
// Tab navigation
content.WriteString(m.renderTabNavigation())
// Main content area - use remaining height
content.WriteString(m.viewport.View())
// Navigation bar
navItems := []layout.NavItem{
{Label: "Overview", Key: "1"},
{Label: "Charts", Key: "2"},
{Label: "Analysis", Key: "3"},
{Label: "Back", Key: "esc"},
}
content.WriteString(m.layout.NavigationBar(navItems, m.currentTab))
// Help text
helpText := "1-3 switch tabs • ←→ navigate tabs • esc back • q quit"
content.WriteString(m.layout.HelpText(helpText))
return m.layout.MainContainer().
Height(m.layout.Height). // Use full height
Render(content.String())
}
func (m *ActivityDetail) renderTabNavigation() string {
var tabs []string
tabWidth := (m.layout.Width - 8) / len(m.tabNames)
for i, tabName := range m.tabNames {
var tabStyle lipgloss.Style
if i == m.currentTab {
tabStyle = lipgloss.NewStyle().
Width(tabWidth).
Height(3).
Background(layout.CardBG).
Foreground(layout.PrimaryOrange).
Bold(true).
Border(lipgloss.RoundedBorder()).
BorderForeground(layout.PrimaryOrange).
Padding(1).
Align(lipgloss.Center)
} else {
tabStyle = lipgloss.NewStyle().
Width(tabWidth).
Height(3).
Background(layout.LightBG).
Foreground(layout.MutedText).
Border(lipgloss.RoundedBorder()).
BorderForeground(layout.MutedText).
Padding(1).
Align(lipgloss.Center)
}
tabs = append(tabs, tabStyle.Render(tabName))
}
return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + "\n"
}
func (m *ActivityDetail) setContent() {
var content strings.Builder
// Debug: Check if activity is nil
if m.activity == nil {
content.WriteString("Activity data is nil!")
m.viewport.SetContent(m.styles.Viewport.Render(content.String()))
m.logger.Debugf("ActivityDetail.setContent() - activity is nil")
m.viewport.SetContent(content.String())
return
}
m.logger.Debugf("ActivityDetail.setContent() - Rendering activity: %s", m.activity.Name)
m.logger.Debugf("ActivityDetail.setContent() - Duration: %v, Distance: %.2f", m.activity.Duration, m.activity.Distance)
m.logger.Debugf("ActivityDetail.setContent() - Metrics: AvgHR=%d, MaxHR=%d, AvgSpeed=%.2f", m.activity.Metrics.AvgHeartRate, m.activity.Metrics.MaxHeartRate, m.activity.Metrics.AvgSpeed)
// Debug info at top
// Activity Details
content.WriteString(m.styles.Title.Render(m.activity.Name))
content.WriteString("\n\n")
// Activity Details with two-column layout
content.WriteString(m.styles.Subtitle.Render("Activity Details"))
content.WriteString("\n")
// First row
content.WriteString(fmt.Sprintf("%s %s %s %s\n",
m.styles.StatName.Render("Date:"),
m.styles.StatValue.Render(m.activity.Date.Format("2006-01-02")),
m.styles.StatName.Render("Type:"),
m.styles.StatValue.Render(m.activity.Type),
))
// Second row
content.WriteString(fmt.Sprintf("%s %s %s %s\n",
m.styles.StatName.Render("Duration:"),
m.styles.StatValue.Render(m.activity.FormattedDuration()),
m.styles.StatName.Render("Distance:"),
m.styles.StatValue.Render(m.activity.FormattedDistance()),
))
// Third row
content.WriteString(fmt.Sprintf("%s %s %s %s\n",
m.styles.StatName.Render("Calories:"),
m.styles.StatValue.Render(fmt.Sprintf("%d kcal", m.activity.Calories)),
m.styles.StatName.Render("Elevation Gain:"),
m.styles.StatValue.Render(fmt.Sprintf("%.0f m", m.activity.Metrics.ElevationGain)),
))
// Fourth row
content.WriteString(fmt.Sprintf("%s %s %s %s\n",
m.styles.StatName.Render("Avg HR:"),
m.styles.StatValue.Render(fmt.Sprintf("%d bpm", m.activity.Metrics.AvgHeartRate)),
m.styles.StatName.Render("Max HR:"),
m.styles.StatValue.Render(fmt.Sprintf("%d bpm", m.activity.Metrics.MaxHeartRate)),
))
// Fifth row (Training Load metrics)
content.WriteString(fmt.Sprintf("%s %s %s %s\n",
m.styles.StatName.Render("Training Stress:"),
m.styles.StatValue.Render(fmt.Sprintf("%.1f", m.activity.Metrics.TrainingStress)),
m.styles.StatName.Render("Recovery Time:"),
m.styles.StatValue.Render(fmt.Sprintf("%d hours", m.activity.Metrics.RecoveryTime)),
))
// Sixth row (Intensity Factor and Speed)
content.WriteString(fmt.Sprintf("%s %s %s %s\n",
m.styles.StatName.Render("Intensity Factor:"),
m.styles.StatValue.Render(fmt.Sprintf("%.1f", m.activity.Metrics.IntensityFactor)),
m.styles.StatName.Render("Avg Speed:"),
m.styles.StatValue.Render(fmt.Sprintf("%.1f km/h", m.activity.Metrics.AvgSpeed)),
))
// Seventh row (Pace)
content.WriteString(fmt.Sprintf("%s %s\n",
m.styles.StatName.Render("Avg Pace:"),
m.styles.StatValue.Render(fmt.Sprintf("%s/km", m.activity.FormattedPace())),
))
// Charts Section
content.WriteString(m.styles.Subtitle.Render("Performance Charts"))
content.WriteString("\n")
// Only show charts if we have data
if len(m.activity.Metrics.HeartRateData) > 0 || len(m.activity.Metrics.ElevationData) > 0 {
// Calculate available height for charts (about 1/3 of screen height)
chartHeight := max(6, (m.height-20)/3)
chartWidth := max(30, m.width-4)
m.hrChart.Width = chartWidth
m.hrChart.Height = chartHeight
m.elevationChart.Width = chartWidth
m.elevationChart.Height = chartHeight
// Render charts with spacing
if len(m.activity.Metrics.HeartRateData) > 0 {
content.WriteString("\n" + m.hrChart.View() + "\n")
}
if len(m.activity.Metrics.ElevationData) > 0 {
content.WriteString("\n" + m.elevationChart.View() + "\n")
}
}
// Analysis Section with formatted output
if m.analysis != "" {
content.WriteString(m.styles.Analysis.Render(
m.styles.Subtitle.Render("AI Analysis"),
))
content.WriteString("\n")
// Split analysis into sections
sections := strings.Split(m.analysis, "## ")
for _, section := range sections {
if strings.TrimSpace(section) == "" {
continue
}
// Split section into title and content
parts := strings.SplitN(section, "\n", 2)
if len(parts) < 2 {
content.WriteString(m.styles.StatValue.Render(section))
continue
}
title := strings.TrimSpace(parts[0])
body := strings.TrimSpace(parts[1])
// Render section title
content.WriteString(m.styles.Title.Render(title))
content.WriteString("\n")
// Format bullet points
lines := strings.Split(body, "\n")
for _, line := range lines {
if strings.HasPrefix(line, "- ") {
content.WriteString("• ")
content.WriteString(strings.TrimPrefix(line, "- "))
} else {
content.WriteString(line)
}
content.WriteString("\n")
}
content.WriteString("\n")
}
switch m.currentTab {
case 0: // Overview
content.WriteString(m.renderOverviewTab())
case 1: // Charts
content.WriteString(m.renderChartsTab())
case 2: // Analysis
content.WriteString(m.renderAnalysisTab())
}
m.viewport.SetContent(content.String())
}
// Helper function for max since it's not available in older Go versions
func max(a, b int) int {
if a > b {
return a
}
return b
func (m *ActivityDetail) renderOverviewTab() string {
var content strings.Builder
// Activity stats cards
content.WriteString(m.renderStatsCards())
content.WriteString("\n\n")
// Two-column layout for detailed metrics
leftContent := m.renderBasicMetrics()
rightContent := m.renderPerformanceMetrics()
content.WriteString(m.layout.TwoColumnLayout(leftContent, rightContent, m.layout.Width/2))
return content.String()
}
func (m *ActivityDetail) renderStatsCards() string {
cardWidth := (m.layout.Width - 16) / 4
cards := []string{
m.layout.StatCard("Duration", m.activity.FormattedDuration(), layout.PrimaryBlue, cardWidth),
m.layout.StatCard("Distance", m.activity.FormattedDistance(), layout.PrimaryGreen, cardWidth),
m.layout.StatCard("Avg Pace", m.activity.FormattedPace(), layout.PrimaryOrange, cardWidth),
m.layout.StatCard("Calories", fmt.Sprintf("%d", m.activity.Calories), layout.PrimaryPink, cardWidth),
}
return lipgloss.JoinHorizontal(lipgloss.Top, cards...)
}
func (m *ActivityDetail) renderBasicMetrics() string {
var content strings.Builder
content.WriteString(lipgloss.NewStyle().
Foreground(layout.PrimaryPurple).
Bold(true).
MarginBottom(1).
Render("Activity Details"))
content.WriteString("\n\n")
metrics := []struct {
label string
value string
color lipgloss.Color
}{
{"Date", m.activity.Date.Format("Monday, January 2, 2006"), layout.LightText},
{"Type", strings.Title(m.activity.Type), layout.PrimaryBlue},
{"Duration", m.activity.FormattedDuration(), layout.PrimaryGreen},
{"Distance", m.activity.FormattedDistance(), layout.PrimaryOrange},
{"Calories", fmt.Sprintf("%d kcal", m.activity.Calories), layout.PrimaryPink},
}
for _, metric := range metrics {
content.WriteString(lipgloss.NewStyle().
Foreground(layout.MutedText).
Width(15).
Render(metric.label + ":"))
content.WriteString(" ")
content.WriteString(lipgloss.NewStyle().
Foreground(metric.color).
Bold(true).
Render(metric.value))
content.WriteString("\n")
}
return content.String()
}
func (m *ActivityDetail) renderPerformanceMetrics() string {
var content strings.Builder
content.WriteString(lipgloss.NewStyle().
Foreground(layout.PrimaryYellow).
Bold(true).
MarginBottom(1).
Render("Performance Metrics"))
content.WriteString("\n\n")
metrics := []struct {
label string
value string
color lipgloss.Color
}{
{"Avg Heart Rate", fmt.Sprintf("%d bpm", m.activity.Metrics.AvgHeartRate), layout.PrimaryPink},
{"Max Heart Rate", fmt.Sprintf("%d bpm", m.activity.Metrics.MaxHeartRate), layout.PrimaryPink},
{"Avg Speed", fmt.Sprintf("%.1f km/h", m.activity.Metrics.AvgSpeed), layout.PrimaryBlue},
{"Elevation Gain", fmt.Sprintf("%.0f m", m.activity.Metrics.ElevationGain), layout.PrimaryGreen},
{"Training Stress", fmt.Sprintf("%.1f TSS", m.activity.Metrics.TrainingStress), layout.PrimaryOrange},
{"Recovery Time", fmt.Sprintf("%d hours", m.activity.Metrics.RecoveryTime), layout.PrimaryPurple},
{"Intensity Factor", fmt.Sprintf("%.2f", m.activity.Metrics.IntensityFactor), layout.PrimaryYellow},
}
for _, metric := range metrics {
content.WriteString(lipgloss.NewStyle().
Foreground(layout.MutedText).
Width(18).
Render(metric.label + ":"))
content.WriteString(" ")
content.WriteString(lipgloss.NewStyle().
Foreground(metric.color).
Bold(true).
Render(metric.value))
content.WriteString("\n")
}
return content.String()
}
func (m *ActivityDetail) renderChartsTab() string {
var content strings.Builder
content.WriteString(lipgloss.NewStyle().
Foreground(layout.PrimaryBlue).
Bold(true).
MarginBottom(2).
Render("Performance Charts"))
content.WriteString("\n\n")
if len(m.activity.Metrics.HeartRateData) == 0 && len(m.activity.Metrics.ElevationData) == 0 {
content.WriteString(lipgloss.NewStyle().
Foreground(layout.MutedText).
Align(lipgloss.Center).
Width(m.layout.Width - 8).
Render("No chart data available for this activity"))
return content.String()
}
// Update chart dimensions for full-width display
chartWidth := m.layout.Width - 12
chartHeight := (m.layout.Height - 15) / 2
m.hrChart.Width = chartWidth
m.hrChart.Height = chartHeight
m.elevationChart.Width = chartWidth
m.elevationChart.Height = chartHeight
if len(m.activity.Metrics.HeartRateData) > 0 {
content.WriteString(m.layout.ChartPanel("Heart Rate", m.hrChart.View(), layout.PrimaryPink))
content.WriteString("\n")
}
if len(m.activity.Metrics.ElevationData) > 0 {
content.WriteString(m.layout.ChartPanel("Elevation", m.elevationChart.View(), layout.PrimaryGreen))
content.WriteString("\n")
}
// Chart legend/info
content.WriteString(lipgloss.NewStyle().
Foreground(layout.MutedText).
Italic(true).
MarginTop(1).
Render("Charts show real-time data throughout the activity duration"))
return content.String()
}
func (m *ActivityDetail) renderAnalysisTab() string {
var content strings.Builder
content.WriteString(lipgloss.NewStyle().
Foreground(layout.PrimaryGreen).
Bold(true).
MarginBottom(2).
Render("AI Analysis"))
content.WriteString("\n\n")
if m.analysis == "" {
content.WriteString(lipgloss.NewStyle().
Foreground(layout.MutedText).
Align(lipgloss.Center).
Width(m.layout.Width - 8).
Render("No AI analysis available for this activity\nAnalysis will be generated automatically in future updates"))
return content.String()
}
// Parse and format analysis sections
sections := strings.Split(m.analysis, "## ")
for i, section := range sections {
if strings.TrimSpace(section) == "" {
continue
}
// Split section into title and content
parts := strings.SplitN(section, "\n", 2)
if len(parts) < 2 {
content.WriteString(lipgloss.NewStyle().
Foreground(layout.LightText).
Render(section))
content.WriteString("\n\n")
continue
}
title := strings.TrimSpace(parts[0])
body := strings.TrimSpace(parts[1])
// Use different colors for different sections
colors := []lipgloss.Color{
layout.PrimaryBlue,
layout.PrimaryGreen,
layout.PrimaryOrange,
layout.PrimaryPink,
layout.PrimaryPurple,
}
sectionColor := colors[i%len(colors)]
// Render section title
content.WriteString(lipgloss.NewStyle().
Foreground(sectionColor).
Bold(true).
MarginBottom(1).
Render("🔍 " + title))
content.WriteString("\n")
// Format content with bullet points
lines := strings.Split(body, "\n")
for _, line := range lines {
if strings.HasPrefix(line, "- ") {
content.WriteString(lipgloss.NewStyle().
Foreground(layout.LightText).
Render(" • " + strings.TrimPrefix(line, "- ")))
} else if strings.TrimSpace(line) != "" {
content.WriteString(lipgloss.NewStyle().
Foreground(layout.LightText).
Render(line))
}
content.WriteString("\n")
}
content.WriteString("\n")
}
return content.String()
}

View File

@@ -1,74 +1,38 @@
// internal/tui/screens/activity_list.go
package screens
import (
"context"
"fmt"
"log"
"strings"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sstent/fitness-tui/internal/garmin"
"github.com/sstent/fitness-tui/internal/storage"
"github.com/sstent/fitness-tui/internal/tui/layout"
"github.com/sstent/fitness-tui/internal/tui/models"
)
var (
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true).Padding(1, 2)
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Italic(true)
)
type ActivityList struct {
list list.Model
storage *storage.ActivityStorage
garminClient garmin.GarminClient
width int
height int
statusMsg string
isLoading bool
}
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())
activities []*models.Activity
totalGarminActivities int // Added for sync status
storage *storage.ActivityStorage
garminClient garmin.GarminClient
layout *layout.Layout
selectedIndex int
statusMsg string
isLoading bool
currentPage int
scrollOffset int
}
func NewActivityList(storage *storage.ActivityStorage, client garmin.GarminClient) *ActivityList {
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([]list.Item{}, 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,
garminClient: client,
layout: layout.NewLayout(80, 24), // Default size
}
}
@@ -81,10 +45,7 @@ func (m *ActivityList) Init() tea.Cmd {
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)
m.layout = layout.NewLayout(msg.Width, msg.Height)
return m, nil
case tea.KeyMsg:
@@ -93,25 +54,52 @@ func (m *ActivityList) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !m.isLoading {
return m, tea.Batch(m.syncActivities, m.setLoading(true))
}
case "up", "k":
if m.selectedIndex > 0 {
m.selectedIndex--
// Calculate visible rows based on current layout
availableHeight := m.layout.Height - 3 - 2 - 2 - 1 - 4
visibleRows := availableHeight - 1
m.updateScrollOffset(visibleRows)
}
case "down", "j":
if m.selectedIndex < len(m.activities)-1 {
m.selectedIndex++
// Calculate visible rows based on current layout
availableHeight := m.layout.Height - 3 - 2 - 2 - 1 - 4
visibleRows := availableHeight - 1
m.updateScrollOffset(visibleRows)
}
case "pgup":
// Calculate visible rows based on current layout
availableHeight := m.layout.Height - 3 - 2 - 2 - 1 - 4
visibleRows := availableHeight - 1
m.selectedIndex = max(0, m.selectedIndex-visibleRows)
m.updateScrollOffset(visibleRows)
case "pgdown":
// Calculate visible rows based on current layout
availableHeight := m.layout.Height - 3 - 2 - 2 - 1 - 4
visibleRows := availableHeight - 1
m.selectedIndex = min(len(m.activities)-1, m.selectedIndex+visibleRows)
m.updateScrollOffset(visibleRows)
case "enter":
if selectedItem := m.list.SelectedItem(); selectedItem != nil {
item, ok := selectedItem.(activityItem)
if !ok {
log.Printf("Failed to cast selected item to activityItem")
return m, nil
}
if len(m.activities) > 0 && m.selectedIndex < len(m.activities) {
activity := m.activities[m.selectedIndex]
return m, func() tea.Msg {
return ActivitySelectedMsg{Activity: item.activity}
return ActivitySelectedMsg{Activity: activity}
}
}
}
case activitiesLoadedMsg:
items := make([]list.Item, len(msg.activities))
for i, activity := range msg.activities {
items[i] = activityItem{activity: activity}
m.activities = msg.activities
if m.selectedIndex >= len(m.activities) {
m.selectedIndex = max(0, len(m.activities)-1)
}
m.list.SetItems(items)
// Calculate visible rows based on current layout
availableHeight := m.layout.Height - 3 - 2 - 2 - 1 - 4
visibleRows := availableHeight - 1
m.updateScrollOffset(visibleRows)
return m, nil
case loadingMsg:
@@ -119,42 +107,288 @@ func (m *ActivityList) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case syncCompleteMsg:
m.statusMsg = fmt.Sprintf("Synced %d activities", msg.count)
m.statusMsg = fmt.Sprintf("Synced %d activities", msg.count)
return m, tea.Batch(m.loadActivities, m.setLoading(false))
case syncErrorMsg:
m.statusMsg = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")). // Red color for errors
MarginTop(1).
Render(fmt.Sprintf("⚠️ Sync failed: %v\nPress 's' to retry", msg.error))
m.statusMsg = fmt.Sprintf("⚠️ Sync failed: %v", msg.error)
return m, m.setLoading(false)
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
return m, nil
}
func (m *ActivityList) updateScrollOffset(visibleRows int) {
if m.selectedIndex < m.scrollOffset {
m.scrollOffset = m.selectedIndex
} else if m.selectedIndex >= m.scrollOffset+visibleRows {
m.scrollOffset = m.selectedIndex - visibleRows + 1
}
// Ensure scroll offset doesn't go negative
if m.scrollOffset < 0 {
m.scrollOffset = 0
}
}
func (m *ActivityList) View() string {
instructions := lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1).
Render("↑↓ navigate • enter view • s sync • q back")
status := lipgloss.NewStyle().
Foreground(lipgloss.Color("242")).
Render(m.statusMsg)
var content strings.Builder
// Header (fixed height)
headerHeight := 3
breadcrumb := "Home > Activities"
if m.isLoading {
status = lipgloss.NewStyle().
Foreground(lipgloss.Color("214")).
Render("Syncing with Garmin...")
breadcrumb += " (Syncing...)"
}
content.WriteString(m.layout.HeaderPanel("Fitness Activities", breadcrumb))
// Removed stats panel per user request
// Calculate available height for main content
navHeight := 2
helpHeight := 1
padding := 4 // Total vertical padding
availableHeight := m.layout.Height - headerHeight - navHeight - helpHeight - padding
// Calculate column widths
summaryWidth := 40
listWidth := m.layout.Width - summaryWidth - 2
if listWidth < 30 {
// Single column - full width
listContent := lipgloss.NewStyle().
Height(availableHeight).
Render(m.renderActivityList())
content.WriteString(m.layout.MainPanel(listContent, m.layout.Width-6))
} else {
// Two columns - use full available height
listContent := lipgloss.NewStyle().
Width(listWidth).
Height(availableHeight).
Render(m.renderActivityList())
summaryContent := lipgloss.NewStyle().
Width(summaryWidth).
Height(availableHeight).
MarginLeft(1).
Render(m.renderSummaryPanel())
content.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, listContent, summaryContent))
}
return fmt.Sprintf("%s\n%s\n%s", m.list.View(), instructions, status)
// Navigation bar
navItems := []layout.NavItem{
{Label: "Activities", Key: "a"},
{Label: "Routes", Key: "r"},
{Label: "Plans", Key: "p"},
{Label: "Workouts", Key: "w"},
{Label: "Analytics", Key: "n"},
}
// Add sync status to nav bar
syncStatus := fmt.Sprintf("Synced: %d/%d", len(m.activities), m.totalGarminActivities)
navBar := m.layout.NavigationBar(navItems, 0)
statusStyle := lipgloss.NewStyle().Foreground(layout.MutedText).Align(lipgloss.Right)
statusText := statusStyle.Render(syncStatus)
navBar = lipgloss.JoinHorizontal(lipgloss.Bottom, navBar, statusText)
content.WriteString(navBar)
// Help text - removed left/right navigation hints
helpText := "↑↓ navigate • enter select • s sync • q quit"
if m.statusMsg != "" {
helpText = m.statusMsg + " • " + helpText
}
content.WriteString(m.layout.HelpText(helpText))
return m.layout.MainContainer().Render(content.String())
}
// Messages and commands
// Removed stats panel per user request
func (m *ActivityList) renderActivityList() string {
if len(m.activities) == 0 {
emptyStyle := lipgloss.NewStyle().
Foreground(layout.MutedText).
Align(lipgloss.Center).
Width(m.layout.Width*2/3 - 6).
Height(10)
return emptyStyle.Render("No activities found\nPress 's' to sync with Garmin")
}
var content strings.Builder
content.WriteString(lipgloss.NewStyle().
Foreground(layout.WhiteText).
Bold(true).
MarginBottom(1).
Render("Recent Activities"))
content.WriteString("\n")
// Calculate dynamic visible rows based on available space
availableHeight := m.layout.Height - 3 - 2 - 2 - 1 - 4 // header, stats, nav, help, padding
visibleRows := availableHeight - 1 // subtract 1 for title
if m.scrollOffset > 0 {
visibleRows-- // reserve space for "more above" indicator
}
if m.scrollOffset+visibleRows < len(m.activities) {
visibleRows-- // reserve space for "more below" indicator
}
// Ensure at least 1 visible row
if visibleRows < 1 {
visibleRows = 1
}
// Calculate visible range based on scroll offset
startIdx := m.scrollOffset
endIdx := min(startIdx+visibleRows, len(m.activities))
// Activity type color mapping
typeColors := map[string]lipgloss.Color{
"cycling": layout.PrimaryBlue,
"running": layout.PrimaryGreen,
"swimming": layout.PrimaryCyan,
"hiking": layout.PrimaryOrange,
"walking": layout.PrimaryYellow,
}
for i := startIdx; i < endIdx; i++ {
activity := m.activities[i]
isSelected := (i == m.selectedIndex)
// Get color for activity type, default to white
color, ok := typeColors[strings.ToLower(activity.Type)]
if !ok {
color = layout.WhiteText
}
// Format activity line
dateStr := activity.Date.Format("2006-01-02")
typeStr := activity.Type
nameStr := activity.Name
// Apply coloring
dateStyle := lipgloss.NewStyle().Foreground(layout.MutedText)
typeStyle := lipgloss.NewStyle().Foreground(color).Bold(true)
nameStyle := lipgloss.NewStyle().Foreground(layout.LightText)
if isSelected {
dateStyle = dateStyle.Bold(true)
typeStyle = typeStyle.Bold(true).Underline(true)
nameStyle = nameStyle.Bold(true)
content.WriteString("> ")
} else {
content.WriteString(" ")
}
content.WriteString(dateStyle.Render(dateStr))
content.WriteString(" ")
content.WriteString(typeStyle.Render(typeStr))
content.WriteString(" ")
content.WriteString(nameStyle.Render(nameStr))
content.WriteString("\n")
}
// Scroll indicators
if startIdx > 0 {
content.WriteString(lipgloss.NewStyle().
Foreground(layout.PrimaryBlue).
Align(lipgloss.Center).
Render("↑ More activities above"))
content.WriteString("\n")
}
if endIdx < len(m.activities) {
content.WriteString(lipgloss.NewStyle().
Foreground(layout.PrimaryBlue).
Align(lipgloss.Center).
Render("↓ More activities below"))
content.WriteString("\n")
}
return content.String()
}
func (m *ActivityList) renderSummaryPanel() string {
var content strings.Builder
// Activity Summary
content.WriteString(lipgloss.NewStyle().
Foreground(layout.WhiteText).
Bold(true).
MarginBottom(1).
Render("Activity Summary"))
content.WriteString("\n\n")
if len(m.activities) > 0 && m.selectedIndex < len(m.activities) {
activity := m.activities[m.selectedIndex]
// Selected activity details
content.WriteString(lipgloss.NewStyle().
Foreground(layout.PrimaryYellow).
Bold(true).
Render(activity.Name))
content.WriteString("\n")
content.WriteString(lipgloss.NewStyle().
Foreground(layout.LightText).
Render(activity.Date.Format("Monday, January 2, 2006")))
content.WriteString("\n\n")
// Key metrics
metrics := []struct {
label string
value string
color lipgloss.Color
}{
{"Duration", activity.FormattedDuration(), layout.PrimaryGreen},
{"Distance", activity.FormattedDistance(), layout.PrimaryBlue},
{"Avg Pace", activity.FormattedPace(), layout.PrimaryOrange},
{"Calories", fmt.Sprintf("%d kcal", activity.Calories), layout.PrimaryPink},
{"Avg HR", fmt.Sprintf("%d bpm", activity.Metrics.AvgHeartRate), layout.PrimaryPurple},
{"Elevation", fmt.Sprintf("%.0f m", activity.Metrics.ElevationGain), layout.PrimaryGreen},
}
for _, metric := range metrics {
content.WriteString(lipgloss.NewStyle().
Foreground(layout.MutedText).
Render(metric.label + ": "))
content.WriteString(lipgloss.NewStyle().
Foreground(metric.color).
Bold(true).
Render(metric.value))
content.WriteString("\n")
}
content.WriteString("\n")
content.WriteString(lipgloss.NewStyle().
Foreground(layout.MutedText).
Italic(true).
Render("Press Enter to view detailed analysis"))
} else {
content.WriteString(lipgloss.NewStyle().
Foreground(layout.MutedText).
Render("Select an activity to view summary"))
}
return content.String()
}
// Helper functions
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
// Messages and commands (unchanged)
type ActivitySelectedMsg struct {
Activity *models.Activity
}
@@ -181,11 +415,15 @@ func (m *ActivityList) syncActivities() tea.Msg {
}
defer m.storage.ReleaseLock()
activities, err := m.garminClient.GetActivities(context.Background(), 50, &garmin.NoopLogger{})
// Increase limit to 10,000 activities
activities, err := m.garminClient.GetActivities(context.Background(), 10000, &garmin.NoopLogger{})
if err != nil {
return syncErrorMsg{err}
}
// Update total count for status display
m.totalGarminActivities = len(activities)
for _, activity := range activities {
if err := m.storage.Save(activity); err != nil {
return syncErrorMsg{err}

View File

@@ -1,287 +0,0 @@
package screens
import (
"errors"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sstent/fitness-tui/internal/garmin"
"github.com/sstent/fitness-tui/internal/storage"
"github.com/sstent/fitness-tui/internal/tui/models"
)
func TestSyncWorkflow(t *testing.T) {
t.Run("successful sync", func(t *testing.T) {
// Create mock activities
mockActivities := []*models.Activity{
{
ID: "1",
Name: "Morning Ride",
Date: time.Now(),
Duration: 45 * time.Minute,
Distance: 15000, // 15km
Type: "cycling",
Metrics: models.ActivityMetrics{
AvgHeartRate: 150,
MaxHeartRate: 180,
AvgSpeed: 20.0,
ElevationGain: 200,
},
},
{
ID: "2",
Name: "Evening Run",
Date: time.Now().Add(-24 * time.Hour),
Duration: 30 * time.Minute,
Distance: 5000, // 5km
Type: "running",
Metrics: models.ActivityMetrics{
AvgHeartRate: 160,
MaxHeartRate: 175,
AvgSpeed: 10.0,
ElevationGain: 50,
},
},
}
mockClient := &garmin.MockClient{
Activities: mockActivities,
}
tempDir := t.TempDir()
activityStorage := storage.NewActivityStorage(tempDir)
model := NewActivityList(activityStorage, mockClient)
// Execute sync operation
msg := model.syncActivities()
// Verify sync was successful
syncComplete, ok := msg.(syncCompleteMsg)
require.True(t, ok, "Expected syncCompleteMsg")
assert.Equal(t, 2, syncComplete.count)
// Verify activities were stored
activities, err := activityStorage.LoadAll()
require.NoError(t, err)
assert.Len(t, activities, 2)
// Verify activity data is correct - note: activities are sorted by date descending
assert.Equal(t, "Morning Ride", activities[0].Name)
assert.Equal(t, "Evening Run", activities[1].Name)
})
t.Run("api failure during sync", func(t *testing.T) {
mockClient := &garmin.MockClient{
GetActivitiesError: errors.New("API unavailable"),
}
tempDir := t.TempDir()
activityStorage := storage.NewActivityStorage(tempDir)
model := NewActivityList(activityStorage, mockClient)
msg := model.syncActivities()
syncError, ok := msg.(syncErrorMsg)
require.True(t, ok, "Expected syncErrorMsg")
assert.Contains(t, syncError.error.Error(), "API unavailable")
// Verify no activities were stored
activities, err := activityStorage.LoadAll()
require.NoError(t, err)
assert.Empty(t, activities)
})
t.Run("storage lock prevents concurrent sync", func(t *testing.T) {
tempDir := t.TempDir()
activityStorage := storage.NewActivityStorage(tempDir)
// Acquire lock to simulate ongoing sync
err := activityStorage.AcquireLock()
require.NoError(t, err)
mockClient := &garmin.MockClient{
Activities: []*models.Activity{{ID: "1", Name: "Test"}},
}
model := NewActivityList(activityStorage, mockClient)
// Try to sync while lock is held
msg := model.syncActivities()
syncError, ok := msg.(syncErrorMsg)
require.True(t, ok, "Expected syncErrorMsg")
assert.Contains(t, syncError.error.Error(), "sync already in progress")
// Clean up
activityStorage.ReleaseLock()
})
t.Run("storage save failure", func(t *testing.T) {
// Create a storage that will fail on save by using read-only directory
activityStorage := storage.NewActivityStorage("/invalid/readonly/path")
mockClient := &garmin.MockClient{
Activities: []*models.Activity{{ID: "1", Name: "Test"}},
}
model := NewActivityList(activityStorage, mockClient)
msg := model.syncActivities()
syncError, ok := msg.(syncErrorMsg)
require.True(t, ok, "Expected syncErrorMsg")
assert.Error(t, syncError.error)
})
}
func TestAuthenticationFailure(t *testing.T) {
mockClient := &garmin.MockClient{
ConnectError: errors.New("authentication failed"),
}
tempDir := t.TempDir()
activityStorage := storage.NewActivityStorage(tempDir)
model := NewActivityList(activityStorage, mockClient)
// Test Init() which calls Connect()
cmd := model.Init()
// The current implementation doesn't handle Connect() errors properly
// This test documents the current behavior and can be updated when fixed
assert.NotNil(t, cmd)
}
func TestSyncStatusMessages(t *testing.T) {
tempDir := t.TempDir()
activityStorage := storage.NewActivityStorage(tempDir)
mockClient := &garmin.MockClient{}
t.Run("loading state during sync", func(t *testing.T) {
model := NewActivityList(activityStorage, mockClient)
// Simulate loading state directly for testing UI
loadingMsg := loadingMsg(true)
updatedModel, _ := model.Update(loadingMsg)
activityList := updatedModel.(*ActivityList)
assert.True(t, activityList.isLoading)
// Verify loading message appears in view
view := activityList.View()
assert.Contains(t, view, "Syncing with Garmin...")
})
t.Run("success message after sync", func(t *testing.T) {
model := NewActivityList(activityStorage, mockClient)
// Simulate successful sync completion
syncMsg := syncCompleteMsg{count: 5}
updatedModel, _ := model.Update(syncMsg)
activityList := updatedModel.(*ActivityList)
assert.Equal(t, "Synced 5 activities", activityList.statusMsg)
})
t.Run("error message display", func(t *testing.T) {
model := NewActivityList(activityStorage, mockClient)
// Simulate sync error
syncMsg := syncErrorMsg{errors.New("connection timeout")}
updatedModel, _ := model.Update(syncMsg)
activityList := updatedModel.(*ActivityList)
view := activityList.View()
assert.Contains(t, view, "⚠️ Sync failed")
assert.Contains(t, view, "connection timeout")
assert.Contains(t, view, "Press 's' to retry")
})
t.Run("prevent multiple concurrent syncs", func(t *testing.T) {
model := NewActivityList(activityStorage, mockClient)
// Start first sync
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}
updatedModel, _ := model.Update(keyMsg)
activityList := updatedModel.(*ActivityList)
activityList.isLoading = true
// Try to start second sync while first is running
updatedModel2, cmd := activityList.Update(keyMsg)
activityList2 := updatedModel2.(*ActivityList)
// Should remain in loading state, no new command issued
assert.True(t, activityList2.isLoading)
assert.Nil(t, cmd)
})
}
func TestActivityLoading(t *testing.T) {
tempDir := t.TempDir()
activityStorage := storage.NewActivityStorage(tempDir)
// Pre-populate storage with test activities
testActivity := &models.Activity{
ID: "test-1",
Name: "Test Activity",
Date: time.Now(),
Type: "cycling",
}
err := activityStorage.Save(testActivity)
require.NoError(t, err)
mockClient := &garmin.MockClient{}
model := NewActivityList(activityStorage, mockClient)
// Test loadActivities method
msg := model.loadActivities()
loadedMsg, ok := msg.(activitiesLoadedMsg)
require.True(t, ok, "Expected activitiesLoadedMsg")
assert.Len(t, loadedMsg.activities, 1)
assert.Equal(t, "Test Activity", loadedMsg.activities[0].Name)
}
func TestActivityListUpdate(t *testing.T) {
tempDir := t.TempDir()
activityStorage := storage.NewActivityStorage(tempDir)
mockClient := &garmin.MockClient{}
model := NewActivityList(activityStorage, mockClient)
t.Run("window resize", func(t *testing.T) {
resizeMsg := tea.WindowSizeMsg{Width: 100, Height: 50}
updatedModel, cmd := model.Update(resizeMsg)
activityList := updatedModel.(*ActivityList)
assert.Equal(t, 100, activityList.width)
assert.Equal(t, 50, activityList.height)
assert.Nil(t, cmd)
})
t.Run("activities loaded", func(t *testing.T) {
activities := []*models.Activity{
{ID: "1", Name: "Activity 1"},
{ID: "2", Name: "Activity 2"},
}
loadedMsg := activitiesLoadedMsg{activities: activities}
updatedModel, cmd := model.Update(loadedMsg)
activityList := updatedModel.(*ActivityList)
items := activityList.list.Items()
assert.Len(t, items, 2)
assert.Nil(t, cmd)
})
t.Run("loading state changes", func(t *testing.T) {
loadingMsg := loadingMsg(true)
updatedModel, cmd := model.Update(loadingMsg)
activityList := updatedModel.(*ActivityList)
assert.True(t, activityList.isLoading)
assert.Nil(t, cmd)
})
}

View File

@@ -44,7 +44,7 @@ func NewClient(domain string) (*Client, error) {
Domain: domain,
HTTPClient: &http.Client{
Jar: jar,
Timeout: 30 * time.Second,
Timeout: 120 * time.Second, // Increased timeout to 2 minutes
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return &errors.APIError{
@@ -160,12 +160,12 @@ func (c *Client) GetUserProfile() (*UserProfile, error) {
}
// GetActivities retrieves recent activities
func (c *Client) GetActivities(limit int) ([]types.Activity, error) {
func (c *Client) GetActivities(limit int, start int) ([]types.Activity, error) {
if limit <= 0 {
limit = 10
}
activitiesURL := fmt.Sprintf("https://connectapi.%s/activitylist-service/activities/search/activities?limit=%d&start=0", c.Domain, limit)
activitiesURL := fmt.Sprintf("https://connectapi.%s/activitylist-service/activities/search/activities?limit=%d&start=%d", c.Domain, limit, start)
req, err := http.NewRequest("GET", activitiesURL, nil)
if err != nil {