mirror of
https://github.com/sstent/aicyclingcoach-go.git
synced 2026-02-15 19:56:26 +00:00
go baby go
This commit is contained in:
BIN
fitness-tui/fitness-tui
Executable file
BIN
fitness-tui/fitness-tui
Executable file
Binary file not shown.
72
fitness-tui/internal/analysis/client.go
Normal file
72
fitness-tui/internal/analysis/client.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package analysis
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
openRouterURL = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OpenRouterClient struct {
|
||||||
|
client *resty.Client
|
||||||
|
config *viper.Viper
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOpenRouterClient(config *viper.Viper) *OpenRouterClient {
|
||||||
|
return &OpenRouterClient{
|
||||||
|
client: resty.New().
|
||||||
|
SetTimeout(30*time.Second).
|
||||||
|
SetHeader("Content-Type", "application/json").
|
||||||
|
SetHeader("HTTP-Referer", "https://github.com/sstent/fitness-tui").
|
||||||
|
SetHeader("Authorization", fmt.Sprintf("Bearer %s", config.GetString("openrouter.apikey"))),
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnalysisRequest struct {
|
||||||
|
ActivityData string `json:"activity_data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnalysisResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenRouterClient) AnalyzeActivity(ctx context.Context, prompt string) (string, error) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"model": c.config.GetString("openrouter.model"),
|
||||||
|
"messages": []map[string]string{
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var response AnalysisResponse
|
||||||
|
resp, err := c.client.R().
|
||||||
|
SetContext(ctx).
|
||||||
|
SetBody(payload).
|
||||||
|
SetResult(&response).
|
||||||
|
Post(openRouterURL)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("API request failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.IsError() {
|
||||||
|
return "", fmt.Errorf("API error: %s", resp.Status())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(response.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("no analysis content in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Choices[0].Message.Content, nil
|
||||||
|
}
|
||||||
46
fitness-tui/internal/analysis/prompts.go
Normal file
46
fitness-tui/internal/analysis/prompts.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package analysis
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/sstent/fitness-tui/internal/tui/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const analysisPromptTemplate = `Analyze this cycling activity for training effectiveness:
|
||||||
|
|
||||||
|
**Activity Details**
|
||||||
|
- Name: %s
|
||||||
|
- Date: %s
|
||||||
|
- Duration: %s
|
||||||
|
- Distance: %s
|
||||||
|
- Elevation Gain: %.0fm
|
||||||
|
- Average Heart Rate: %d bpm
|
||||||
|
- Max Heart Rate: %d bpm
|
||||||
|
- Average Speed: %.1f km/h
|
||||||
|
|
||||||
|
**Training Focus**
|
||||||
|
The athlete intended this workout to be: %s
|
||||||
|
|
||||||
|
**Analysis Request**
|
||||||
|
Provide structured feedback in markdown format covering:
|
||||||
|
1. Training goal achievement assessment
|
||||||
|
2. Heart rate zone analysis
|
||||||
|
3. Pacing strategy evaluation
|
||||||
|
4. Recovery recommendations
|
||||||
|
5. Suggested improvements for similar future workouts
|
||||||
|
|
||||||
|
Keep responses concise and focused on actionable insights. Use bullet points and headings for organization.`
|
||||||
|
|
||||||
|
func BuildAnalysisPrompt(activity *models.Activity, workoutGoal string) string {
|
||||||
|
return fmt.Sprintf(analysisPromptTemplate,
|
||||||
|
activity.Name,
|
||||||
|
activity.Date.Format("2006-01-02 15:04"),
|
||||||
|
activity.FormattedDuration(),
|
||||||
|
activity.FormattedDistance(),
|
||||||
|
activity.Metrics.ElevationGain,
|
||||||
|
activity.Metrics.AvgHeartRate,
|
||||||
|
activity.Metrics.MaxHeartRate,
|
||||||
|
activity.Metrics.AvgSpeed,
|
||||||
|
workoutGoal,
|
||||||
|
)
|
||||||
|
}
|
||||||
92
fitness-tui/internal/tui/components/chart.go
Normal file
92
fitness-tui/internal/tui/components/chart.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
BlockEmpty = " "
|
||||||
|
Block1 = "▁"
|
||||||
|
Block2 = "▂"
|
||||||
|
Block3 = "▃"
|
||||||
|
Block4 = "▄"
|
||||||
|
Block5 = "▅"
|
||||||
|
Block6 = "▆"
|
||||||
|
Block7 = "▇"
|
||||||
|
Block8 = "█"
|
||||||
|
)
|
||||||
|
|
||||||
|
var blockChars = []string{BlockEmpty, Block1, Block2, Block3, Block4, Block5, Block6, Block7, Block8}
|
||||||
|
|
||||||
|
type Chart struct {
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Data []float64
|
||||||
|
Title string
|
||||||
|
style lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChart(data []float64, width, height int, title string) *Chart {
|
||||||
|
return &Chart{
|
||||||
|
Data: data,
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
Title: title,
|
||||||
|
style: lipgloss.NewStyle().Padding(0, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chart) View() string {
|
||||||
|
if len(c.Data) == 0 {
|
||||||
|
return c.style.Render("No data available")
|
||||||
|
}
|
||||||
|
|
||||||
|
min, max := findMinMax(c.Data)
|
||||||
|
sampled := sampleData(c.Data, c.Width)
|
||||||
|
|
||||||
|
var chart strings.Builder
|
||||||
|
for _, value := range sampled {
|
||||||
|
normalized := (value - min) / (max - min)
|
||||||
|
level := int(normalized * 8)
|
||||||
|
if level > 8 {
|
||||||
|
level = 8
|
||||||
|
}
|
||||||
|
chart.WriteString(blockChars[level])
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.style.Render(fmt.Sprintf("%s\n%s", c.Title, chart.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func findMinMax(data []float64) (float64, float64) {
|
||||||
|
min, max := data[0], data[0]
|
||||||
|
for _, v := range data {
|
||||||
|
if v < min {
|
||||||
|
min = v
|
||||||
|
}
|
||||||
|
if v > max {
|
||||||
|
max = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return min, max
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleData(data []float64, targetLength int) []float64 {
|
||||||
|
if len(data) <= targetLength {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
sampled := make([]float64, targetLength)
|
||||||
|
ratio := float64(len(data)) / float64(targetLength)
|
||||||
|
|
||||||
|
for i := 0; i < targetLength; i++ {
|
||||||
|
index := int(float64(i) * ratio)
|
||||||
|
if index >= len(data) {
|
||||||
|
index = len(data) - 1
|
||||||
|
}
|
||||||
|
sampled[i] = data[index]
|
||||||
|
}
|
||||||
|
return sampled
|
||||||
|
}
|
||||||
38
fitness-tui/internal/tui/components/chart_test.go
Normal file
38
fitness-tui/internal/tui/components/chart_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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.Equal(t, "Single\n▄▄▄▄▄", 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.Equal(t, "Series\n▁▂▄▆█", 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.Len(t, view, 20+6) // Title + chart characters
|
||||||
|
})
|
||||||
|
}
|
||||||
131
fitness-tui/internal/tui/screens/activity_detail.go
Normal file
131
fitness-tui/internal/tui/screens/activity_detail.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"github.com/sstent/fitness-tui/internal/tui/components"
|
||||||
|
"github.com/sstent/fitness-tui/internal/tui/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActivityDetail struct {
|
||||||
|
activity *models.Activity
|
||||||
|
analysis string
|
||||||
|
viewport viewport.Model
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
styles *Styles
|
||||||
|
hrChart *components.Chart
|
||||||
|
elevationChart *components.Chart
|
||||||
|
}
|
||||||
|
|
||||||
|
type Styles struct {
|
||||||
|
Title lipgloss.Style
|
||||||
|
Subtitle lipgloss.Style
|
||||||
|
StatName lipgloss.Style
|
||||||
|
StatValue lipgloss.Style
|
||||||
|
Analysis lipgloss.Style
|
||||||
|
Viewport lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewActivityDetail(activity *models.Activity, analysis string) *ActivityDetail {
|
||||||
|
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(2),
|
||||||
|
Viewport: lipgloss.NewStyle().Padding(0, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
vp := viewport.New(0, 0)
|
||||||
|
return &ActivityDetail{
|
||||||
|
activity: activity,
|
||||||
|
analysis: analysis,
|
||||||
|
viewport: vp,
|
||||||
|
styles: styles,
|
||||||
|
hrChart: components.NewChart(activity.Metrics.HeartRateData, 40, 4, "Heart Rate (bpm)"),
|
||||||
|
elevationChart: components.NewChart(activity.Metrics.ElevationData, 40, 4, "Elevation (m)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ActivityDetail) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Width = msg.Width
|
||||||
|
m.viewport.Height = msg.Height - 4
|
||||||
|
chartWidth := msg.Width/2 - 4
|
||||||
|
m.hrChart.Width = chartWidth
|
||||||
|
m.elevationChart.Width = chartWidth
|
||||||
|
m.setContent()
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc":
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.viewport, cmd = m.viewport.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ActivityDetail) View() string {
|
||||||
|
return m.viewport.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ActivityDetail) setContent() {
|
||||||
|
var content strings.Builder
|
||||||
|
|
||||||
|
// Activity Details
|
||||||
|
content.WriteString(m.styles.Title.Render(m.activity.Name))
|
||||||
|
content.WriteString("\n\n")
|
||||||
|
|
||||||
|
content.WriteString(m.styles.Subtitle.Render("Activity Details"))
|
||||||
|
content.WriteString("\n")
|
||||||
|
content.WriteString(fmt.Sprintf("%s %s\n",
|
||||||
|
m.styles.StatName.Render("Date:"),
|
||||||
|
m.styles.StatValue.Render(m.activity.Date.Format("2006-01-02")),
|
||||||
|
))
|
||||||
|
content.WriteString(fmt.Sprintf("%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()),
|
||||||
|
))
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
m.viewport.SetContent(m.styles.Viewport.Render(content.String()))
|
||||||
|
}
|
||||||
79
fitness-tui/internal/tui/screens/help.go
Normal file
79
fitness-tui/internal/tui/screens/help.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
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", "Select item"},
|
||||||
|
helpItem{"s", "Sync activities"},
|
||||||
|
helpItem{"a", "View activities"},
|
||||||
|
helpItem{"c", "View charts"},
|
||||||
|
helpItem{"q", "Return/Quit"},
|
||||||
|
helpItem{"h/?", "Show this help"},
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
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())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user