mirror of
https://github.com/sstent/aicyclingcoach-go.git
synced 2026-01-25 16:41:48 +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