This commit is contained in:
2025-09-17 17:30:18 -07:00
parent 6bad6cae00
commit 84ba6432c2
65 changed files with 434 additions and 765 deletions

151
internal/tui/app.go Normal file
View File

@@ -0,0 +1,151 @@
// internal/tui/app.go
package tui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/sstent/fitness-tui/internal/config"
"github.com/sstent/fitness-tui/internal/garmin"
"github.com/sstent/fitness-tui/internal/storage"
"github.com/sstent/fitness-tui/internal/tui/screens"
)
type App struct {
currentModel tea.Model
activityStorage *storage.ActivityStorage
garminClient *garmin.Client
logger garmin.Logger
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, config *config.Config) *App {
if logger == nil {
logger = &garmin.NoopLogger{}
}
// Initialize with a placeholder screen - actual size will be set by WindowSizeMsg
activityList := screens.NewActivityList(activityStorage, garminClient, config)
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 {
return a.currentModel.Init()
}
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
// 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":
// 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)
// Create new activity detail screen
detail := screens.NewActivityDetail(msg.Activity, "", msg.Config, a.logger)
a.pushScreen(detail)
return a, detail.Init()
case screens.BackToListMsg:
a.logger.Debugf("App.Update() - Received BackToListMsg")
return a, a.goBack()
}
// Delegate to the current model
updatedModel, cmd := a.currentModel.Update(msg)
a.currentModel = updatedModel
a.updateStackTop(updatedModel)
return a, cmd
}
func (a *App) View() string {
return a.currentModel.View()
}
func (a *App) Run() error {
// 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

@@ -4,9 +4,10 @@ import (
"fmt"
"math"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/sstent/aicyclingcoach-go/fitness-tui/internal/types"
"github.com/sstent/fitness-tui/internal/types"
)
// Chart represents an ASCII chart component
@@ -16,112 +17,209 @@ type Chart struct {
Width int
Height int
Color lipgloss.Color
downsampler *types.Downsampler
Min, Max float64
Downsampled []types.DownsampledPoint
Unit string
Mode string // "bar" or "sparkline"
XLabels []string
YMax float64
}
// NewChart creates a new Chart instance
func NewChart(data []float64, title string) *Chart {
return &Chart{
Data: data,
Title: title,
Width: 0, // Will be set based on terminal size
Height: 10,
Color: lipgloss.Color("39"), // Default blue
downsampler: types.NewDownsampler(),
// NewChart creates a new chart instance
func NewChart(data []float64, title, unit string, width, height int, color lipgloss.Color) *Chart {
c := &Chart{
Data: data,
Title: title,
Width: width,
Height: height,
Color: color,
Unit: unit,
Mode: "sparkline",
}
}
// WithSize sets the chart dimensions
func (c *Chart) WithSize(width, height int) *Chart {
c.Width = width
c.Height = height
if len(data) > 0 {
// Use downsampled data for min/max to improve performance
// Using empty timestamps array since we only need value downsampling
downsampled := types.DownsampleLTTB(data, make([]time.Time, len(data)), width)
c.Downsampled = downsampled
values := make([]float64, len(downsampled))
for i, point := range downsampled {
values[i] = point.Value
}
c.Min, c.Max = minMax(values)
}
return c
}
// WithColor sets the chart color
func (c *Chart) WithColor(color lipgloss.Color) *Chart {
c.Color = color
// NewBarChart creates a bar chart with axis labels
func NewBarChart(data []float64, title string, width, height int, color lipgloss.Color, xLabels []string, yMax float64) *Chart {
c := NewChart(data, title, "", width, height, color)
c.Mode = "bar"
c.XLabels = xLabels
c.YMax = yMax
return c
}
// View renders the chart
func (c *Chart) View() string {
if len(c.Data) == 0 {
return fmt.Sprintf("%s\nNo data available", c.Title)
return c.renderNoData()
}
// Downsample data if needed
processedData := c.downsampler.Process(c.Data, c.Width)
if c.Width <= 10 || c.Height <= 4 {
return c.renderTooSmall()
}
// Normalize data to chart height
min, max := minMax(processedData)
normalized := normalize(processedData, min, max, c.Height-1)
if c.Mode == "bar" && c.YMax == 0 {
return c.renderNoData()
}
// Build chart
// Recalculate if dimensions changed
if len(c.Downsampled) != c.Width {
c.Downsampled = types.DownsampleLTTB(c.Data, make([]time.Time, len(c.Data)), c.Width)
values := make([]float64, len(c.Downsampled))
for i, point := range c.Downsampled {
values[i] = point.Value
}
c.Min, c.Max = minMax(values)
}
return lipgloss.NewStyle().
MaxWidth(c.Width).
Render(c.renderTitle() + "\n" + c.renderChart())
}
func (c *Chart) renderTitle() string {
return lipgloss.NewStyle().
Bold(true).
Foreground(c.Color).
Render(c.Title)
}
func (c *Chart) renderChart() string {
if c.Max == c.Min && c.Mode != "bar" {
return c.renderConstantData()
}
if c.Mode == "bar" {
return c.renderBarChart()
}
return c.renderSparkline()
}
func (c *Chart) renderBarChart() string {
var sb strings.Builder
sb.WriteString(c.Title + "\n")
chartHeight := c.Height - 3 // Reserve space for title and labels
// Create Y-axis labels
yLabels := createYAxisLabels(min, max, c.Height-1)
// Calculate scaling factor
maxValue := c.YMax
if maxValue == 0 {
maxValue = c.Max
}
scale := float64(chartHeight-1) / maxValue
for i := c.Height - 1; i >= 0; i-- {
// Y-axis labels
yIncrement := maxValue / float64(chartHeight-1)
for i := chartHeight - 1; i >= 0; i-- {
label := fmt.Sprintf("%3.0f │", maxValue-(yIncrement*float64(i)))
sb.WriteString(label)
if i == 0 {
sb.WriteString("") // Bottom-left corner
} else if i == c.Height-1 {
sb.WriteString("↑") // Top axis indicator
} else {
sb.WriteString("│") // Y-axis line
sb.WriteString(" " + strings.Repeat("─", c.Width))
break
}
// Add Y-axis label
if i < len(yLabels) {
sb.WriteString(yLabels[i])
} else {
sb.WriteString(" ")
}
// Add chart bars
for j := 0; j < len(normalized); j++ {
if i == 0 {
sb.WriteString("─") // X-axis
// Draw bars
for _, point := range c.Downsampled {
barHeight := int(math.Round(point.Value * scale))
if barHeight >= i {
sb.WriteString("#")
} else {
if normalized[j] >= float64(i) {
sb.WriteString("█") // Full block
} else {
// Gradient blocks based on fractional part
frac := normalized[j] - math.Floor(normalized[j])
if normalized[j] >= float64(i-1) && frac > 0.75 {
sb.WriteString("▇")
} else if normalized[j] >= float64(i-1) && frac > 0.5 {
sb.WriteString("▅")
} else if normalized[j] >= float64(i-1) && frac > 0.25 {
sb.WriteString("▃")
} else if normalized[j] >= float64(i-1) && frac > 0 {
sb.WriteString("▁")
} else {
sb.WriteString(" ")
}
}
sb.WriteString(" ")
}
}
sb.WriteString("\n")
}
// Add X-axis title
sb.WriteString(" " + strings.Repeat(" ", len(yLabels[0])+1) + "→ Time\n")
// X-axis labels
sb.WriteString("\n ")
for i, label := range c.XLabels {
if i >= len(c.Downsampled) {
break
}
if i == 0 {
sb.WriteString(" ")
}
sb.WriteString(fmt.Sprintf("%-*s", c.Width/len(c.XLabels), label))
}
// Apply color styling
style := lipgloss.NewStyle().Foreground(c.Color)
return style.Render(sb.String())
return sb.String()
}
func (c *Chart) renderSparkline() string {
var sb strings.Builder
chartHeight := c.Height - 3 // Reserve rows for title and labels
minLabel := fmt.Sprintf("%.0f%s", c.Min, c.Unit)
maxLabel := fmt.Sprintf("%.0f%s", c.Max, c.Unit)
sb.WriteString(fmt.Sprintf("%5s ", maxLabel))
for i, point := range c.Downsampled {
if i >= c.Width {
break
}
normalized := (point.Value - c.Min) / (c.Max - c.Min)
barHeight := int(math.Round(normalized * float64(chartHeight-1)))
sb.WriteString(c.renderBar(barHeight, chartHeight))
}
sb.WriteString("\n")
// Add X axis with min label
sb.WriteString(fmt.Sprintf("%5s ", minLabel))
sb.WriteString(strings.Repeat("─", c.Width))
return sb.String()
}
func (c *Chart) renderBar(height, maxHeight int) string {
if height <= 0 {
return " "
}
// Use Unicode block characters for better resolution
blocks := []string{" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"}
index := int(float64(height) / float64(maxHeight) * 8)
if index >= len(blocks) {
index = len(blocks) - 1
}
return lipgloss.NewStyle().
Foreground(c.Color).
Render(blocks[index])
}
func (c *Chart) renderNoData() string {
return lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Render(fmt.Sprintf("%s: No data", c.Title))
}
func (c *Chart) renderTooSmall() string {
return lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Render(fmt.Sprintf("%s: Terminal too small", c.Title))
}
func (c *Chart) renderConstantData() string {
return lipgloss.NewStyle().
Foreground(lipgloss.Color("214")).
Render(fmt.Sprintf("%s: Constant value %.2f", c.Title, c.Data[0]))
}
// minMax finds min and max values in a slice
func minMax(data []float64) (min, max float64) {
if len(data) == 0 {
return 0, 0
}
min = data[0]
max = data[0]
min, max = data[0], data[0]
for _, v := range data {
if v < min {
min = v
@@ -132,34 +230,3 @@ func minMax(data []float64) (min, max float64) {
}
return min, max
}
// normalize scales values to fit within chart height
func normalize(data []float64, min, max float64, height int) []float64 {
if max == min || height <= 0 {
return make([]float64, len(data))
}
scale := float64(height) / (max - min)
normalized := make([]float64, len(data))
for i, v := range data {
normalized[i] = (v - min) * scale
}
return normalized
}
// createYAxisLabels creates labels for Y-axis
func createYAxisLabels(min, max float64, height int) []string {
labels := make([]string, height+1)
step := (max - min) / float64(height)
for i := 0; i <= height; i++ {
value := min + float64(i)*step
label := fmt.Sprintf("%.0f", value)
// Pad to consistent width (5 characters)
if len(label) < 5 {
label = strings.Repeat(" ", 5-len(label)) + label
}
labels[height-i] = label
}
return labels
}

View File

@@ -0,0 +1,47 @@
package components
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestChartView(t *testing.T) {
t.Run("empty data", func(t *testing.T) {
chart := NewChart(nil, 10, 4, "Test")
view := chart.View()
assert.Contains(t, view, "No data available")
})
t.Run("single data point", func(t *testing.T) {
chart := NewChart([]float64{50}, 5, 4, "Single")
view := chart.View()
assert.Contains(t, view, "Single")
assert.Contains(t, view, "▄")
})
t.Run("multiple data points", func(t *testing.T) {
data := []float64{10, 20, 30, 40, 50}
chart := NewChart(data, 5, 4, "Series")
view := chart.View()
assert.Contains(t, view, "Series")
// Check that we have various block characters representing the data progression
assert.Contains(t, view, "▂")
assert.Contains(t, view, "▄")
assert.Contains(t, view, "▆")
assert.Contains(t, view, "█")
})
t.Run("downsampling", func(t *testing.T) {
data := make([]float64, 100)
for i := range data {
data[i] = float64(i)
}
chart := NewChart(data, 20, 4, "Downsample")
view := chart.View()
assert.Contains(t, view, "Downsample")
// Just verify it contains some block characters, don't check exact length due to styling
assert.Contains(t, view, "▁")
assert.Contains(t, view, "▇") // Use ▇ instead of █
})
}

View File

@@ -0,0 +1,65 @@
package components
import (
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var (
spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69"))
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).MarginTop(1)
)
type Spinner struct {
spinner spinner.Model
message string
quitting bool
}
func NewSpinner(message string) Spinner {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = spinnerStyle
return Spinner{
spinner: s,
message: message,
}
}
func (s Spinner) Init() tea.Cmd {
return s.spinner.Tick
}
func (s Spinner) Update(msg tea.Msg) (Spinner, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "esc", "ctrl+c":
s.quitting = true
return s, tea.Quit
default:
return s, nil
}
default:
var cmd tea.Cmd
s.spinner, cmd = s.spinner.Update(msg)
return s, cmd
}
}
func (s Spinner) View() string {
str := lipgloss.JoinHorizontal(lipgloss.Center,
s.spinner.View(),
" "+s.message,
)
if s.quitting {
return str + "\n"
}
return str
}
func (s Spinner) SetMessage(msg string) Spinner {
s.message = msg
return s
}

View File

@@ -0,0 +1,13 @@
package layout
type Layout struct {
Width int
Height int
}
func NewLayout(width, height int) *Layout {
return &Layout{
Width: width,
Height: height,
}
}

View File

@@ -0,0 +1,98 @@
package models
import (
"fmt"
"time"
"github.com/sstent/fitness-tui/internal/types"
)
type Activity struct {
ID string
Name string
Description string
Type string // Garmin activity type (e.g., "running", "cycling")
ActivityType string // Activity type for AI analysis prompts (e.g., "running", "cycling", "hiking")
Date time.Time
Duration time.Duration
Distance float64 // meters
Elevation float64
Calories int // in kilocalories
Metrics ActivityMetrics
FilePath string // Path to original activity file (e.g., GPX/FIT)
}
type ActivityMetrics struct {
// Core metrics
AvgHeartRate int
MaxHeartRate int
AvgPace float64 // seconds per km
AvgSpeed float64 // km/h
ElevationGain float64 // meters
ElevationLoss float64 // meters
RecoveryTime int // hours
IntensityFactor float64
// Raw data streams
HeartRateData []float64 `json:"heart_rate_data"`
ElevationData []float64 `json:"elevation_data"`
PowerData []float64 `json:"power_data,omitempty"`
CadenceData []float64 `json:"cadence_data,omitempty"`
SpeedData []float64 `json:"speed_data,omitempty"`
TemperatureData []float64 `json:"temperature_data,omitempty"`
// Downsampled metrics (for AI analysis)
DownsampledHR []types.DownsampledPoint `json:"downsampled_hr,omitempty"`
DownsampledPower []types.DownsampledPoint `json:"downsampled_power,omitempty"`
DownsampledCadence []types.DownsampledPoint `json:"downsampled_cadence,omitempty"`
DownsampledSpeed []types.DownsampledPoint `json:"downsampled_speed,omitempty"`
// Power metrics
AvgPower float64 `json:"avg_power"`
MaxPower float64 `json:"max_power"`
NormalizedPower float64 `json:"normalized_power"`
FTP float64 `json:"ftp"` // Functional Threshold Power
// Cadence metrics
AvgCadence float64 `json:"avg_cadence"`
MaxCadence float64 `json:"max_cadence"`
// Running metrics
GroundContactTime int `json:"ground_contact_time"` // milliseconds
VerticalOscillation int `json:"vertical_oscillation"` // millimeters
// Hiking metrics
AscentRate float64 `json:"ascent_rate"` // meters/hour
DescentRate float64 `json:"descent_rate"` // meters/hour
AvgTemperature float64 `json:"avg_temperature"` // Celsius
// Analysis-specific fields
TargetZones string `json:"target_zones"` // e.g., "Z2: 115-140bpm"
TrainingLoad float64 `json:"training_load"` // Training load score
TrainingStressScore float64 `json:"training_stress_score"` // TSS score
ElevationProfile string `json:"elevation_profile"` // e.g., "Hilly with 3 major climbs"
FatigueLevel string `json:"fatigue_level"` // e.g., "Moderate"
}
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("%02d:%02d", minutes, seconds)
}
func (a *Activity) FormattedDistance() string {
return fmt.Sprintf("%.2fkm", a.Distance/1000)
}
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)
}

View File

@@ -0,0 +1,656 @@
// internal/tui/screens/activity_detail.go
package screens
import (
"context"
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sstent/fitness-tui/internal/analysis"
"github.com/sstent/fitness-tui/internal/config"
"github.com/sstent/fitness-tui/internal/garmin"
"github.com/sstent/fitness-tui/internal/storage"
"github.com/sstent/fitness-tui/internal/tui/components"
"github.com/sstent/fitness-tui/internal/tui/models"
"github.com/sstent/fitness-tui/internal/tui/styles"
)
type BackToListMsg struct{}
type AnalysisCompleteMsg struct {
Analysis string
}
type AnalysisFailedMsg struct {
Error error
}
type AnalysisProgressMsg struct {
Progress string
}
type ActivityDetail struct {
activity *models.Activity
analysis string
viewport viewport.Model
hrChart *components.Chart
powerChart *components.Chart
elevationChart *components.Chart
logger garmin.Logger
config *config.Config
styles *styles.Styles
ready bool
currentTab int // 0: Overview, 1: Charts, 2: Analysis
tabNames []string
generating bool
analysisSpinner spinner.Model
analysisProgress string
lastError error // Store the last analysis error
}
func NewActivityDetail(activity *models.Activity, analysis string, config *config.Config, logger garmin.Logger) *ActivityDetail {
st := styles.NewStyles()
if logger == nil {
logger = &garmin.NoopLogger{}
}
vp := viewport.New(80, 20)
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
ad := &ActivityDetail{
activity: activity,
analysis: analysis,
viewport: vp,
logger: logger,
config: config,
styles: st,
hrChart: components.NewChart(
activity.Metrics.HeartRateData,
"Heart Rate",
"bpm",
40,
4,
lipgloss.Color("#FF0000"),
),
powerChart: components.NewChart(
activity.Metrics.PowerData,
"Power",
"watts",
40,
4,
lipgloss.Color("#00FF00"),
),
elevationChart: components.NewChart(
activity.Metrics.ElevationData,
"Elevation",
"m",
40,
4,
lipgloss.Color("#0000FF"),
),
tabNames: []string{"Overview", "Charts", "Analysis"},
analysisSpinner: s,
analysisProgress: "Ready to analyze",
}
ad.setContent()
return ad
}
func (m *ActivityDetail) Init() tea.Cmd {
return tea.Batch(
m.analysisSpinner.Tick,
func() tea.Msg {
return tea.WindowSizeMsg{Width: 80, Height: 24}
},
)
}
func (m *ActivityDetail) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var (
cmd tea.Cmd
cmds []tea.Cmd
)
switch msg := msg.(type) {
case tea.WindowSizeMsg:
// 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":
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()
case "a": // Trigger analysis (uses cached if available)
if m.currentTab == 2 && !m.generating {
m.generating = true
m.analysisProgress = "Checking cache..."
m.lastError = nil // Clear previous error
cmds = append(cmds, tea.Batch(
m.analysisSpinner.Tick,
m.generateAnalysisCmd(false),
))
}
case "A": // Force re-analyze (skip cache)
if m.currentTab == 2 && !m.generating {
m.generating = true
m.analysisProgress = "Forcing new analysis..."
m.lastError = nil // Clear previous error
cmds = append(cmds, tea.Batch(
m.analysisSpinner.Tick,
m.generateAnalysisCmd(true),
))
}
case "r": // Refresh or retry
if m.currentTab == 2 {
if !m.generating {
if m.analysis == "" && m.lastError != nil {
// Retry failed analysis
m.generating = true
m.analysisProgress = "Retrying analysis..."
m.lastError = nil // Clear previous error
cmds = append(cmds, tea.Batch(
m.analysisSpinner.Tick,
m.generateAnalysisCmd(false),
))
} else {
// Refresh existing analysis
m.analysis = ""
m.analysisProgress = "Refreshing analysis..."
m.generating = true
m.lastError = nil // Clear previous error
cmds = append(cmds, tea.Batch(
m.analysisSpinner.Tick,
m.generateAnalysisCmd(false),
))
}
}
}
}
case AnalysisCompleteMsg:
m.generating = false
if msg.Analysis != "" {
m.analysis = msg.Analysis
}
m.analysisProgress = "Analysis complete"
m.setContent()
case AnalysisFailedMsg:
m.generating = false
m.lastError = msg.Error
m.analysisProgress = "Analysis failed"
m.setContent()
case AnalysisProgressMsg:
m.analysisProgress = msg.Progress
m.setContent()
}
// Update spinner if generating
if m.generating {
m.analysisSpinner, cmd = m.analysisSpinner.Update(msg)
cmds = append(cmds, cmd)
}
// Update viewport
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m *ActivityDetail) View() string {
if !m.ready {
return "Loading activity details..."
}
var content strings.Builder
// Header with activity name
header := m.styles.HeaderPanel.Render(m.activity.Name)
content.WriteString(header)
// Tab navigation
content.WriteString(m.renderTabNavigation())
// Main content area - use remaining height
content.WriteString(m.viewport.View())
// Navigation bar
navItems := []styles.NavItem{
{Label: "Overview", Key: "1"},
{Label: "Charts", Key: "2"},
{Label: "Analysis", Key: "3"},
{Label: "Back", Key: "esc"},
}
content.WriteString(m.styles.NavigationBar(navItems, m.currentTab))
// Help text
helpText := "1-3 switch tabs • ←→ navigate tabs • esc back"
if m.currentTab == 2 {
helpText += " • a: analyze • r: refresh/retry"
}
helpText += " • q quit"
content.WriteString(m.styles.HelpText.Render(helpText))
return m.styles.MainContainer.
Render(content.String())
}
func (m *ActivityDetail) renderTabNavigation() string {
var tabs []string
tabWidth := (m.styles.Dimensions.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(m.styles.CardBG).
Foreground(m.styles.PrimaryOrange).
BorderForeground(m.styles.PrimaryOrange).
Bold(true).
Border(lipgloss.RoundedBorder()).
BorderForeground(m.styles.PrimaryOrange).
Padding(1).
Align(lipgloss.Center)
} else {
tabStyle = lipgloss.NewStyle().
Width(tabWidth).
Height(3).
Background(m.styles.LightBG).
Foreground(m.styles.MutedText).
Border(lipgloss.RoundedBorder()).
BorderForeground(m.styles.MutedText).
Padding(1).
Align(lipgloss.Center)
}
tabs = append(tabs, tabStyle.Render(tabName))
}
return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + "\n"
}
func (m *ActivityDetail) setContent() {
m.viewport.Width = m.styles.Dimensions.Width
m.viewport.Height = m.styles.Dimensions.Height
var content strings.Builder
if m.activity == nil {
content.WriteString("Activity data is nil!")
m.viewport.SetContent(content.String())
return
}
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())
}
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.styles.TwoColumnLayout(leftContent, rightContent, m.styles.Dimensions.Width/2))
return content.String()
}
func (m *ActivityDetail) renderStatsCards() string {
cardWidth := (m.styles.Dimensions.Width - 16) / 4
cards := []string{
m.styles.StatCard("Duration", m.activity.FormattedDuration(), m.styles.PrimaryBlue, cardWidth),
m.styles.StatCard("Distance", m.activity.FormattedDistance(), m.styles.PrimaryGreen, cardWidth),
m.styles.StatCard("Avg Pace", m.activity.FormattedPace(), m.styles.PrimaryOrange, cardWidth),
m.styles.StatCard("Calories", fmt.Sprintf("%d", m.activity.Calories), m.styles.PrimaryPink, cardWidth),
}
return lipgloss.JoinHorizontal(lipgloss.Top, cards...)
}
func (m *ActivityDetail) renderBasicMetrics() string {
var content strings.Builder
content.WriteString(lipgloss.NewStyle().
Foreground(m.styles.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"), m.styles.LightText},
{"Type", strings.Title(m.activity.Type), m.styles.PrimaryBlue},
{"Duration", m.activity.FormattedDuration(), m.styles.PrimaryGreen},
{"Distance", m.activity.FormattedDistance(), m.styles.PrimaryOrange},
{"Calories", fmt.Sprintf("%d kcal", m.activity.Calories), m.styles.PrimaryPink},
}
for _, metric := range metrics {
content.WriteString(lipgloss.NewStyle().
Foreground(m.styles.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(m.styles.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), m.styles.PrimaryPink},
{"Max Heart Rate", fmt.Sprintf("%d bpm", m.activity.Metrics.MaxHeartRate), m.styles.PrimaryPink},
{"Avg Speed", fmt.Sprintf("%.1f km/h", m.activity.Metrics.AvgSpeed), m.styles.PrimaryBlue},
{"Elevation Gain", fmt.Sprintf("%.0f m", m.activity.Metrics.ElevationGain), m.styles.PrimaryGreen},
{"Training Stress", fmt.Sprintf("%.1f TSS", m.activity.Metrics.TrainingStressScore), m.styles.PrimaryOrange},
{"Recovery Time", fmt.Sprintf("%d hours", m.activity.Metrics.RecoveryTime), m.styles.PrimaryPurple},
{"Intensity Factor", fmt.Sprintf("%.2f", m.activity.Metrics.IntensityFactor), m.styles.PrimaryYellow},
}
for _, metric := range metrics {
content.WriteString(lipgloss.NewStyle().
Foreground(m.styles.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
chartsAvailable := false
content.WriteString(lipgloss.NewStyle().
Foreground(m.styles.PrimaryBlue).
Bold(true).
MarginBottom(2).
Render("Performance Charts"))
content.WriteString("\n\n")
// Calculate chart dimensions based on terminal size
chartWidth := m.viewport.Width - 12
chartHeight := (m.viewport.Height - 15) / 3 // Divide by 3 for three charts
// Update chart dimensions only if they've changed
if m.hrChart.Width != chartWidth || m.hrChart.Height != chartHeight {
m.hrChart.Width = chartWidth
m.hrChart.Height = chartHeight
m.powerChart.Width = chartWidth
m.powerChart.Height = chartHeight
m.elevationChart.Width = chartWidth
m.elevationChart.Height = chartHeight
}
// Render HR chart if data exists
if len(m.activity.Metrics.HeartRateData) > 0 {
content.WriteString(m.hrChart.View())
content.WriteString("\n")
chartsAvailable = true
}
// Render Power chart if data exists
if len(m.activity.Metrics.PowerData) > 0 {
content.WriteString(m.powerChart.View())
content.WriteString("\n")
chartsAvailable = true
}
// Render Elevation chart if data exists
if len(m.activity.Metrics.ElevationData) > 0 {
content.WriteString(m.elevationChart.View())
content.WriteString("\n")
chartsAvailable = true
}
if !chartsAvailable {
content.WriteString(lipgloss.NewStyle().
Foreground(m.styles.MutedText).
Align(lipgloss.Center).
Width(m.viewport.Width - 8).
Render("No chart data available for this activity"))
} else {
// Chart legend/info
content.WriteString(lipgloss.NewStyle().
Foreground(m.styles.MutedText).
Italic(true).
MarginTop(1).
Render("Charts show real-time data throughout the activity duration"))
}
return content.String()
}
func (m *ActivityDetail) generateAnalysisCmd(forceRefresh bool) tea.Cmd {
return func() tea.Msg {
// Create storage and analysis clients
analysisCache := storage.NewAnalysisCache(m.config.StoragePath)
analysisClient := analysis.NewOpenRouterClient(m.config)
// Check cache unless forcing refresh
if !forceRefresh {
cachedContent, _, err := analysisCache.GetAnalysis(m.activity.ID)
if err == nil && cachedContent != "" {
return AnalysisCompleteMsg{
Analysis: cachedContent,
}
}
}
// Generate new analysis
analysisContent, err := analysisClient.AnalyzeActivity(context.Background(), analysis.PromptParams{
Activity: m.activity,
})
if err != nil {
return AnalysisFailedMsg{
Error: fmt.Errorf("analysis generation failed: %w", err),
}
}
// Cache the analysis
meta := storage.AnalysisMetadata{
ActivityID: m.activity.ID,
GeneratedAt: time.Now(),
ModelUsed: m.config.OpenRouter.Model,
}
if err := analysisCache.StoreAnalysis(m.activity, analysisContent, meta); err != nil {
m.logger.Warnf("Failed to cache analysis: %v", err)
}
return AnalysisCompleteMsg{
Analysis: analysisContent,
}
}
}
func (m *ActivityDetail) renderAnalysisTab() string {
var content strings.Builder
content.WriteString(lipgloss.NewStyle().
Foreground(m.styles.PrimaryGreen).
Bold(true).
MarginBottom(2).
Render("AI Analysis"))
content.WriteString("\n\n")
if m.generating {
spinnerView := lipgloss.JoinHorizontal(lipgloss.Left,
m.analysisSpinner.View(),
" "+m.analysisProgress,
)
content.WriteString(
lipgloss.JoinVertical(lipgloss.Left,
spinnerView,
"\n\n",
lipgloss.NewStyle().
Foreground(m.styles.MutedText).
Italic(true).
Render("Analyzing workout data with AI..."),
),
)
} else if m.analysis == "" {
if m.lastError != nil {
content.WriteString(
lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000")).
Render(m.analysisProgress),
)
content.WriteString("\n\n")
content.WriteString(
lipgloss.NewStyle().
Foreground(m.styles.LightText).
Render(m.lastError.Error()),
)
content.WriteString("\n\n")
content.WriteString(
lipgloss.NewStyle().
Foreground(m.styles.LightText).
Render("Press 'r' to retry or 'a' to try again"),
)
} else {
content.WriteString(
lipgloss.JoinVertical(lipgloss.Left,
lipgloss.NewStyle().
Foreground(m.styles.MutedText).
Align(lipgloss.Center).
Width(m.viewport.Width-8).
Render("No AI analysis available for this activity"),
"\n\n",
lipgloss.NewStyle().
Foreground(m.styles.LightText).
Render("Press 'a' to generate analysis"),
),
)
}
} else {
// 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(m.styles.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{
m.styles.PrimaryBlue,
m.styles.PrimaryGreen,
m.styles.PrimaryOrange,
m.styles.PrimaryPink,
m.styles.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(m.styles.LightText).
Render(" • " + strings.TrimPrefix(line, "- ")))
} else if strings.TrimSpace(line) != "" {
content.WriteString(lipgloss.NewStyle().
Foreground(m.styles.LightText).
Render(line))
}
content.WriteString("\n")
}
content.WriteString("\n")
}
}
return content.String()
}

View File

@@ -0,0 +1,98 @@
package screens
import (
"fmt"
"testing"
"github.com/charmbracelet/bubbles/viewport"
"github.com/sstent/fitness-tui/internal/config"
"github.com/sstent/fitness-tui/internal/garmin"
"github.com/sstent/fitness-tui/internal/tui/models"
"github.com/sstent/fitness-tui/internal/tui/styles"
"github.com/stretchr/testify/assert"
)
func TestActivityDetail_NilActivityHandling(t *testing.T) {
// Setup with nil activity
ad := NewActivityDetail(nil, "", &config.Config{}, &garmin.NoopLogger{})
ad.setContent()
// Verify nil check in setContent
assert.Contains(t, ad.viewport.View(), "Activity data is nil!", "Should show nil activity message")
// Verify nil check in renderOverviewTab
content := ad.renderOverviewTab()
assert.Contains(t, content, "Activity data is nil!", "Should show nil activity in overview")
}
func TestActivityDetail_NilMetricsHandling(t *testing.T) {
// Create activity with nil metrics
activity := &models.Activity{
Name: "Test Activity",
Metrics: &models.ActivityMetrics{
HeartRateData: []float64{},
PowerData: []float64{},
ElevationData: []float64{},
},
}
ad := NewActivityDetail(activity, "", &config.Config{}, &garmin.NoopLogger{})
// Switch to charts tab
ad.currentTab = 1
ad.setContent()
content := ad.viewport.View()
assert.Contains(t, content, "No chart data available", "Should handle nil metrics")
assert.NotContains(t, content, "Heart Rate", "Should not render HR chart")
assert.NotContains(t, content, "Power", "Should not render power chart")
assert.NotContains(t, content, "Elevation", "Should not render elevation chart")
}
func TestActivityDetail_AnalysisErrorStates(t *testing.T) {
activity := &models.Activity{Name: "Test Activity"}
ad := NewActivityDetail(activity, "", &config.Config{}, &garmin.NoopLogger{})
ad.currentTab = 2
// Test error display
ad.lastError = fmt.Errorf("test error")
ad.setContent()
content := ad.viewport.View()
assert.Contains(t, content, "Analysis failed", "Should show error state")
assert.Contains(t, content, "test error", "Should show error message")
assert.Contains(t, content, "Press 'r' to retry", "Should show retry prompt")
// Test generating state
ad.generating = true
ad.analysisProgress = "Generating..."
ad.setContent()
content = ad.viewport.View()
assert.Contains(t, content, "Generating...", "Should show progress message")
}
func BenchmarkActivityDetail_Render(b *testing.B) {
activity := &models.Activity{
Name: "Benchmark Activity",
Metrics: &models.ActivityMetrics{
HeartRateData: make([]float64, 1000),
PowerData: make([]float64, 1000),
ElevationData: make([]float64, 1000),
},
}
ad := NewActivityDetail(activity, "", &config.Config{}, &garmin.NoopLogger{})
// Mock styles and layout for rendering
ad.styles = &styles.Styles{
CardBG: "#ffffff",
PrimaryOrange: "#e67e22",
MutedText: "#7f8c8d",
LightText: "#bdc3c7",
}
ad.viewport = viewport.New(100, 40)
b.ResetTimer()
for i := 0; i < b.N; i++ {
ad.setContent()
_ = ad.View()
}
}

View File

@@ -0,0 +1,450 @@
// internal/tui/screens/activity_list.go
package screens
import (
"context"
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sstent/fitness-tui/internal/config"
"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"
"github.com/sstent/fitness-tui/internal/tui/styles"
)
type ActivityList struct {
activities []*models.Activity
totalGarminActivities int // Added for sync status
storage *storage.ActivityStorage
garminClient garmin.GarminClient
config *config.Config
layout *layout.Layout
styles *styles.Styles
selectedIndex int
statusMsg string
isLoading bool
currentPage int
scrollOffset int
}
func NewActivityList(storage *storage.ActivityStorage, client garmin.GarminClient, config *config.Config) *ActivityList {
return &ActivityList{
storage: storage,
garminClient: client,
config: config,
layout: layout.NewLayout(80, 24), // Default size
styles: styles.NewStyles(),
}
}
func (m *ActivityList) Init() tea.Cmd {
// Initialize Garmin connection synchronously for now
m.garminClient.Connect(&garmin.NoopLogger{})
return m.loadActivities
}
func (m *ActivityList) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.layout.Width = msg.Width
m.layout.Height = msg.Height
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "s":
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 len(m.activities) > 0 && m.selectedIndex < len(m.activities) {
activity := m.activities[m.selectedIndex]
return m, func() tea.Msg {
return ActivitySelectedMsg{
Activity: activity,
Config: m.config,
}
}
}
}
case activitiesLoadedMsg:
m.activities = msg.activities
if m.selectedIndex >= len(m.activities) {
m.selectedIndex = max(0, len(m.activities)-1)
}
// 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:
m.isLoading = bool(msg)
return m, nil
case syncCompleteMsg:
m.statusMsg = fmt.Sprintf("✓ Synced %d activities", msg.count)
return m, tea.Batch(m.loadActivities, m.setLoading(false))
case syncErrorMsg:
m.statusMsg = fmt.Sprintf("⚠️ Sync failed: %v", msg.error)
return m, m.setLoading(false)
}
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 {
var content strings.Builder
// Header (fixed height)
headerHeight := 3
breadcrumb := "Home > Activities"
if m.isLoading {
breadcrumb += " (Syncing...)"
}
content.WriteString(m.styles.HeaderPanel.Render(fmt.Sprintf("%s\n%s", "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.styles.MainPanel.Render(listContent))
} 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))
}
// Navigation bar
navItems := []styles.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.styles.NavigationBar(navItems, 0)
statusStyle := lipgloss.NewStyle().Foreground(m.styles.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.styles.HelpText.Render(helpText))
return m.styles.MainContainer.Render(content.String())
}
// Removed stats panel per user request
func (m *ActivityList) renderActivityList() string {
if len(m.activities) == 0 {
emptyStyle := lipgloss.NewStyle().
Foreground(m.styles.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(m.styles.LightText).
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": m.styles.PrimaryBlue,
"running": m.styles.PrimaryGreen,
"swimming": m.styles.PrimaryBlue,
"hiking": m.styles.PrimaryOrange,
"walking": m.styles.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 = m.styles.LightText
}
// Format activity line
dateStr := activity.Date.Format("2006-01-02")
typeStr := activity.Type
nameStr := activity.Name
// Apply coloring
dateStyle := lipgloss.NewStyle().Foreground(m.styles.MutedText)
typeStyle := lipgloss.NewStyle().Foreground(color).Bold(true)
nameStyle := lipgloss.NewStyle().Foreground(m.styles.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(m.styles.PrimaryBlue).
Align(lipgloss.Center).
Render("↑ More activities above"))
content.WriteString("\n")
}
if endIdx < len(m.activities) {
content.WriteString(lipgloss.NewStyle().
Foreground(m.styles.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(m.styles.LightText).
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(m.styles.PrimaryYellow).
Bold(true).
Render(activity.Name))
content.WriteString("\n")
content.WriteString(lipgloss.NewStyle().
Foreground(m.styles.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(), m.styles.PrimaryGreen},
{"Distance", activity.FormattedDistance(), m.styles.PrimaryBlue},
{"Avg Pace", activity.FormattedPace(), m.styles.PrimaryOrange},
{"Calories", fmt.Sprintf("%d kcal", activity.Calories), m.styles.PrimaryPink},
{"Avg HR", fmt.Sprintf("%d bpm", activity.Metrics.AvgHeartRate), m.styles.PrimaryPurple},
{"Elevation", fmt.Sprintf("%.0f m", activity.Metrics.ElevationGain), m.styles.PrimaryGreen},
}
for _, metric := range metrics {
content.WriteString(lipgloss.NewStyle().
Foreground(m.styles.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(m.styles.MutedText).
Italic(true).
Render("Press Enter to view detailed analysis"))
} else {
content.WriteString(lipgloss.NewStyle().
Foreground(m.styles.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
Config *config.Config
}
type activitiesLoadedMsg struct {
activities []*models.Activity
}
type loadingMsg bool
type syncErrorMsg struct{ error }
type syncCompleteMsg struct{ count int }
func (m *ActivityList) loadActivities() tea.Msg {
activities, err := m.storage.LoadAll()
if err != nil {
return syncErrorMsg{err}
}
return activitiesLoadedMsg{activities: activities}
}
func (m *ActivityList) syncActivities() tea.Msg {
if err := m.storage.AcquireLock(); err != nil {
return syncErrorMsg{err}
}
defer m.storage.ReleaseLock()
// 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}
}
}
return syncCompleteMsg{count: len(activities)}
}
func (m *ActivityList) setLoading(isLoading bool) tea.Cmd {
return func() tea.Msg {
return loadingMsg(isLoading)
}
}

View File

@@ -0,0 +1,82 @@
package screens
import (
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type helpItem struct {
key string
description string
}
func (i helpItem) Title() string { return i.key }
func (i helpItem) Description() string { return i.description }
func (i helpItem) FilterValue() string { return i.key }
type Help struct {
list list.Model
width int
height int
}
func NewHelp() *Help {
items := []list.Item{
helpItem{"↑↓", "Navigate items"},
helpItem{"Enter", "View activity details"},
helpItem{"s", "Sync activities with Garmin Connect"},
helpItem{"a", "Analyze selected activity (list view)"},
helpItem{"c", "View charts"},
helpItem{"q", "Return/Quit"},
helpItem{"h/?", "Show this help"},
helpItem{"a (detail view)", "Analyze activity in detail view"},
helpItem{"r (detail view)", "Retry analysis in detail view"},
helpItem{"Analysis Workflow", "1. Select activity\n2. Press 'a'\n3. System checks cache\n4. Fetches analysis if needed\n5. Displays insights"},
}
delegate := list.NewDefaultDelegate()
delegate.Styles.SelectedTitle = lipgloss.NewStyle().
Foreground(lipgloss.Color("170")).
Bold(true)
delegate.Styles.SelectedDesc = lipgloss.NewStyle().
Foreground(lipgloss.Color("243"))
l := list.New(items, delegate, 0, 0)
l.Title = "Keyboard Shortcuts and Features"
l.Styles.Title = lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true).
MarginLeft(2)
return &Help{list: l}
}
func (m *Help) Init() tea.Cmd {
return nil
}
func (m *Help) 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 - 2)
case tea.KeyMsg:
switch msg.String() {
case "q", "esc":
return m, tea.Quit
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m *Help) View() string {
return lipgloss.NewStyle().
Padding(1, 2).
Render(m.list.View())
}

View File

@@ -0,0 +1,110 @@
package styles
import (
"fmt"
"github.com/charmbracelet/lipgloss"
)
type Styles struct {
Dimensions struct {
Width int
Height int
}
PrimaryBlue lipgloss.Color
PrimaryGreen lipgloss.Color
PrimaryOrange lipgloss.Color
PrimaryPink lipgloss.Color
PrimaryPurple lipgloss.Color
PrimaryYellow lipgloss.Color
LightBG lipgloss.Color
DarkBG lipgloss.Color
CardBG lipgloss.Color
MutedText lipgloss.Color
LightText lipgloss.Color
PrimaryText lipgloss.Color
HeaderPanel lipgloss.Style
MainPanel lipgloss.Style
NavigationBar func([]NavItem, int) string
HelpText lipgloss.Style
MainContainer lipgloss.Style
StatCard func(string, string, lipgloss.Color, int) string
TwoColumnLayout func(string, string, int) string
}
func NewStyles() *Styles {
s := &Styles{}
s.PrimaryBlue = lipgloss.Color("#3498db")
s.PrimaryGreen = lipgloss.Color("#2ecc71")
s.PrimaryOrange = lipgloss.Color("#e67e22")
s.PrimaryPink = lipgloss.Color("#e84393")
s.PrimaryPurple = lipgloss.Color("#9b59b6")
s.PrimaryYellow = lipgloss.Color("#f1c40f")
s.LightBG = lipgloss.Color("#ecf0f1")
s.DarkBG = lipgloss.Color("#2c3e50")
s.CardBG = lipgloss.Color("#ffffff")
s.MutedText = lipgloss.Color("#7f8c8d")
s.LightText = lipgloss.Color("#bdc3c7")
s.PrimaryText = lipgloss.Color("#2c3e50")
// Initialize dimensions with default values
s.Dimensions.Width = 80
s.Dimensions.Height = 24
s.HeaderPanel = lipgloss.NewStyle().
Foreground(s.PrimaryText).
Background(s.PrimaryBlue).
Bold(true).
Padding(0, 1).
Width(s.Dimensions.Width)
s.HelpText = lipgloss.NewStyle().
Foreground(s.MutedText).
Padding(0, 1)
s.MainContainer = lipgloss.NewStyle().
Padding(1, 2)
s.StatCard = func(title, value string, color lipgloss.Color, width int) string {
return lipgloss.NewStyle().
Background(s.CardBG).
Foreground(color).
Padding(1).
Width(width).
Render(fmt.Sprintf("%s\n%s", title, value))
}
s.TwoColumnLayout = func(left, right string, width int) string {
return lipgloss.JoinHorizontal(lipgloss.Top,
lipgloss.NewStyle().Width(width/2).Render(left),
lipgloss.NewStyle().Width(width/2).Render(right),
)
}
s.NavigationBar = func(items []NavItem, activeIdx int) string {
var navItems []string
for i, item := range items {
style := lipgloss.NewStyle().
Padding(0, 1).
Foreground(s.MutedText)
if i == activeIdx {
style = style.
Foreground(s.PrimaryText).
Bold(true)
}
navItems = append(navItems, style.Render(item.Label))
}
return lipgloss.JoinHorizontal(lipgloss.Left, navItems...)
}
return s
}
// NavItem defines a navigation bar item
type NavItem struct {
Label string
Key string
}