mirror of
https://github.com/sstent/aicyclingcoach-go.git
synced 2026-04-05 04:23:56 +00:00
sync
This commit is contained in:
151
internal/tui/app.go
Normal file
151
internal/tui/app.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
47
internal/tui/components/chart_test.go
Normal file
47
internal/tui/components/chart_test.go
Normal 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 █
|
||||
})
|
||||
}
|
||||
65
internal/tui/components/spinner.go
Normal file
65
internal/tui/components/spinner.go
Normal 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
|
||||
}
|
||||
13
internal/tui/layout/layout.go
Normal file
13
internal/tui/layout/layout.go
Normal 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,
|
||||
}
|
||||
}
|
||||
98
internal/tui/models/activity.go
Normal file
98
internal/tui/models/activity.go
Normal 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)
|
||||
}
|
||||
656
internal/tui/screens/activity_detail.go
Normal file
656
internal/tui/screens/activity_detail.go
Normal 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()
|
||||
}
|
||||
98
internal/tui/screens/activity_detail_test.go
Normal file
98
internal/tui/screens/activity_detail_test.go
Normal 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()
|
||||
}
|
||||
}
|
||||
450
internal/tui/screens/activity_list.go
Normal file
450
internal/tui/screens/activity_list.go
Normal 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)
|
||||
}
|
||||
}
|
||||
82
internal/tui/screens/help.go
Normal file
82
internal/tui/screens/help.go
Normal 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())
|
||||
}
|
||||
110
internal/tui/styles/styles.go
Normal file
110
internal/tui/styles/styles.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user