mirror of
https://github.com/sstent/aicyclingcoach-go.git
synced 2026-03-17 18:35:25 +00:00
sync
This commit is contained in:
Binary file not shown.
@@ -10,7 +10,9 @@ import (
|
||||
)
|
||||
|
||||
type App struct {
|
||||
currentModel tea.Model
|
||||
currentModel tea.Model
|
||||
activityStorage *storage.ActivityStorage
|
||||
garminClient *garmin.Client
|
||||
}
|
||||
|
||||
func NewApp(activityStorage *storage.ActivityStorage, garminClient *garmin.Client) *App {
|
||||
@@ -18,7 +20,9 @@ func NewApp(activityStorage *storage.ActivityStorage, garminClient *garmin.Clien
|
||||
activityList := screens.NewActivityList(activityStorage, garminClient)
|
||||
|
||||
return &App{
|
||||
currentModel: activityList,
|
||||
currentModel: activityList,
|
||||
activityStorage: activityStorage,
|
||||
garminClient: garminClient,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +37,18 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case "ctrl+c", "q":
|
||||
return a, tea.Quit
|
||||
}
|
||||
case screens.ActivitySelectedMsg:
|
||||
fmt.Printf("DEBUG: App.Update() - Received ActivitySelectedMsg for: %s\n", msg.Activity.Name)
|
||||
// For now, use empty analysis - we'll implement analysis caching later
|
||||
detail := screens.NewActivityDetail(msg.Activity, "")
|
||||
a.currentModel = detail
|
||||
return a, detail.Init()
|
||||
case screens.BackToListMsg:
|
||||
fmt.Println("DEBUG: App.Update() - Received BackToListMsg")
|
||||
// Re-initialize the activity list when navigating back
|
||||
activityList := screens.NewActivityList(a.activityStorage, a.garminClient)
|
||||
a.currentModel = activityList
|
||||
return a, activityList.Init()
|
||||
}
|
||||
|
||||
// Delegate to the current model
|
||||
|
||||
@@ -47,26 +47,60 @@ func (c *Chart) View() string {
|
||||
min, max := findMinMax(c.Data)
|
||||
sampled := sampleData(c.Data, c.Width)
|
||||
|
||||
var chart strings.Builder
|
||||
for _, value := range sampled {
|
||||
var level int
|
||||
// Create chart rows from top to bottom
|
||||
rows := make([]string, c.Height)
|
||||
for i := range rows {
|
||||
rows[i] = strings.Repeat(" ", c.Width)
|
||||
}
|
||||
|
||||
// Fill chart based on values
|
||||
for i, value := range sampled {
|
||||
if max == min {
|
||||
// All values are the same, use middle level
|
||||
level = 4
|
||||
// Handle case where all values are equal
|
||||
level := c.Height / 2
|
||||
if level >= c.Height {
|
||||
level = c.Height - 1
|
||||
}
|
||||
rows[level] = replaceAtIndex(rows[level], blockChars[7], i)
|
||||
} else {
|
||||
normalized := (value - min) / (max - min)
|
||||
level = int(normalized * 8)
|
||||
if level > 8 {
|
||||
level = 8
|
||||
level := int(normalized * float64(c.Height-1))
|
||||
if level >= c.Height {
|
||||
level = c.Height - 1
|
||||
}
|
||||
if level < 0 {
|
||||
level = 0
|
||||
}
|
||||
// Draw from bottom up
|
||||
rowIndex := c.Height - 1 - level
|
||||
rows[rowIndex] = replaceAtIndex(rows[rowIndex], blockChars[7], i)
|
||||
}
|
||||
chart.WriteString(blockChars[level])
|
||||
}
|
||||
|
||||
return c.style.Render(fmt.Sprintf("%s\n%s", c.Title, chart.String()))
|
||||
// Add Y-axis labels
|
||||
chartWithLabels := ""
|
||||
if c.Height > 3 {
|
||||
chartWithLabels += fmt.Sprintf("%5.1f ┤\n", max)
|
||||
for i := 1; i < c.Height-1; i++ {
|
||||
chartWithLabels += " │ " + rows[i] + "\n"
|
||||
}
|
||||
chartWithLabels += fmt.Sprintf("%5.1f ┤ %s", min, rows[c.Height-1])
|
||||
} else {
|
||||
// Fallback for small heights
|
||||
for _, row := range rows {
|
||||
chartWithLabels += row + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// Add X-axis title
|
||||
return c.style.Render(fmt.Sprintf("%s\n%s", c.Title, chartWithLabels))
|
||||
}
|
||||
|
||||
// Helper function to replace character at index
|
||||
func replaceAtIndex(in string, r string, i int) string {
|
||||
out := []rune(in)
|
||||
out[i] = []rune(r)[0]
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func findMinMax(data []float64) (float64, float64) {
|
||||
|
||||
@@ -19,14 +19,17 @@ type Activity struct {
|
||||
}
|
||||
|
||||
type ActivityMetrics struct {
|
||||
AvgHeartRate int
|
||||
MaxHeartRate int
|
||||
AvgPace float64 // seconds per km
|
||||
AvgSpeed float64 // km/h
|
||||
ElevationGain float64 // meters
|
||||
ElevationLoss float64 // meters
|
||||
HeartRateData []float64
|
||||
ElevationData []float64
|
||||
AvgHeartRate int
|
||||
MaxHeartRate int
|
||||
AvgPace float64 // seconds per km
|
||||
AvgSpeed float64 // km/h
|
||||
ElevationGain float64 // meters
|
||||
ElevationLoss float64 // meters
|
||||
TrainingStress float64 // TSS score
|
||||
RecoveryTime int // hours
|
||||
IntensityFactor float64
|
||||
HeartRateData []float64
|
||||
ElevationData []float64
|
||||
}
|
||||
|
||||
func (a *Activity) FormattedDuration() string {
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"github.com/sstent/fitness-tui/internal/tui/models"
|
||||
)
|
||||
|
||||
type BackToListMsg struct{}
|
||||
|
||||
type ActivityDetail struct {
|
||||
activity *models.Activity
|
||||
analysis string
|
||||
@@ -43,7 +45,7 @@ func NewActivityDetail(activity *models.Activity, analysis string) *ActivityDeta
|
||||
}
|
||||
|
||||
vp := viewport.New(0, 0)
|
||||
return &ActivityDetail{
|
||||
ad := &ActivityDetail{
|
||||
activity: activity,
|
||||
analysis: analysis,
|
||||
viewport: vp,
|
||||
@@ -51,9 +53,13 @@ func NewActivityDetail(activity *models.Activity, analysis string) *ActivityDeta
|
||||
hrChart: components.NewChart(activity.Metrics.HeartRateData, 40, 4, "Heart Rate (bpm)"),
|
||||
elevationChart: components.NewChart(activity.Metrics.ElevationData, 40, 4, "Elevation (m)"),
|
||||
}
|
||||
ad.setContent()
|
||||
return ad
|
||||
}
|
||||
|
||||
func (m *ActivityDetail) Init() tea.Cmd {
|
||||
// Initialize content immediately instead of waiting for WindowSizeMsg
|
||||
m.setContent()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -62,8 +68,7 @@ func (m *ActivityDetail) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.viewport.Width = msg.Width
|
||||
m.viewport.Height = msg.Height - 4
|
||||
m.viewport = viewport.New(msg.Width, msg.Height-4)
|
||||
chartWidth := msg.Width/2 - 4
|
||||
m.hrChart.Width = chartWidth
|
||||
m.elevationChart.Width = chartWidth
|
||||
@@ -81,50 +86,152 @@ func (m *ActivityDetail) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m *ActivityDetail) View() string {
|
||||
return m.viewport.View()
|
||||
instructions := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
MarginTop(1).
|
||||
Render("esc back • q quit")
|
||||
|
||||
return fmt.Sprintf("%s\n%s", m.viewport.View(), instructions)
|
||||
}
|
||||
|
||||
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()))
|
||||
fmt.Println("DEBUG: ActivityDetail.setContent() - activity is nil")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG: ActivityDetail.setContent() - Rendering activity: %s\n", m.activity.Name)
|
||||
fmt.Printf("DEBUG: ActivityDetail.setContent() - Duration: %v, Distance: %.2f\n", m.activity.Duration, m.activity.Distance)
|
||||
fmt.Printf("DEBUG: ActivityDetail.setContent() - Metrics: AvgHR=%d, MaxHR=%d, AvgSpeed=%.2f\n", m.activity.Metrics.AvgHeartRate, m.activity.Metrics.MaxHeartRate, m.activity.Metrics.AvgSpeed)
|
||||
|
||||
// Debug info at top
|
||||
content.WriteString(fmt.Sprintf("DEBUG: Viewport W=%d H=%d, Activity: %s\n", m.width, m.height, m.activity.Name))
|
||||
content.WriteString("\n")
|
||||
|
||||
// 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")
|
||||
content.WriteString(fmt.Sprintf("%s %s\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),
|
||||
))
|
||||
content.WriteString(fmt.Sprintf("%s %s\n",
|
||||
|
||||
// Second row
|
||||
content.WriteString(fmt.Sprintf("%s %s %s %s\n",
|
||||
m.styles.StatName.Render("Duration:"),
|
||||
m.styles.StatValue.Render(m.activity.FormattedDuration()),
|
||||
))
|
||||
content.WriteString(fmt.Sprintf("%s %s\n",
|
||||
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")
|
||||
charts := lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
m.hrChart.View(),
|
||||
lipgloss.NewStyle().Width(2).Render(" "),
|
||||
m.elevationChart.View(),
|
||||
)
|
||||
content.WriteString(charts)
|
||||
content.WriteString("\n\n")
|
||||
|
||||
// Analysis Section
|
||||
// Only show charts if we have data
|
||||
if len(m.activity.Metrics.HeartRateData) > 0 || len(m.activity.Metrics.ElevationData) > 0 {
|
||||
charts := lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
m.hrChart.View(),
|
||||
lipgloss.NewStyle().Width(2).Render(" "),
|
||||
m.elevationChart.View(),
|
||||
)
|
||||
content.WriteString(charts)
|
||||
content.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Analysis Section with formatted output
|
||||
if m.analysis != "" {
|
||||
content.WriteString(m.styles.Analysis.Render(
|
||||
m.styles.Subtitle.Render("AI Analysis"),
|
||||
))
|
||||
content.WriteString("\n")
|
||||
content.WriteString(m.styles.StatValue.Render(m.analysis))
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
m.viewport.SetContent(m.styles.Viewport.Render(content.String()))
|
||||
|
||||
@@ -3,6 +3,7 @@ package screens
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -94,8 +95,14 @@ func (m *ActivityList) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
case "enter":
|
||||
if selectedItem := m.list.SelectedItem(); selectedItem != nil {
|
||||
// TODO: Navigate to activity detail
|
||||
return m, nil
|
||||
item, ok := selectedItem.(activityItem)
|
||||
if !ok {
|
||||
log.Printf("Failed to cast selected item to activityItem")
|
||||
return m, nil
|
||||
}
|
||||
return m, func() tea.Msg {
|
||||
return ActivitySelectedMsg{Activity: item.activity}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user