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

View File

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