go baby go

This commit is contained in:
2025-09-12 18:58:15 -07:00
parent 95086eafb5
commit 790360ebb5
8 changed files with 458 additions and 0 deletions

BIN
fitness-tui/fitness-tui Executable file

Binary file not shown.

View 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
}

View 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,
)
}

View 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
}

View 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
})
}

View 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()))
}

View 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())
}

0
go.sum Normal file
View File