This commit is contained in:
2025-09-17 08:59:24 -07:00
parent 0a3af79cc9
commit 6bad6cae00
97 changed files with 2582 additions and 6917 deletions

View File

@@ -0,0 +1,69 @@
package circuitbreaker
import (
"context"
"time"
"github.com/sony/gobreaker"
)
// CircuitBreaker wraps gobreaker.CircuitBreaker with context support
type CircuitBreaker struct {
cb *gobreaker.CircuitBreaker
}
// New creates a new CircuitBreaker with the given name and settings
func New(name string, st gobreaker.Settings) *CircuitBreaker {
return &CircuitBreaker{
cb: gobreaker.NewCircuitBreaker(st),
}
}
// Execute runs the given function with circuit breaker protection
func (c *CircuitBreaker) Execute(ctx context.Context, req func() (interface{}, error)) (interface{}, error) {
// Check if context is already canceled
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
resultChan := make(chan interface{}, 1)
errChan := make(chan error, 1)
go func() {
res, err := c.cb.Execute(func() (interface{}, error) {
return req()
})
if err != nil {
errChan <- err
return
}
resultChan <- res
}()
select {
case res := <-resultChan:
return res, nil
case err := <-errChan:
return nil, err
case <-ctx.Done():
return nil, ctx.Err()
}
}
// DefaultSettings returns sensible default circuit breaker settings
func DefaultSettings(name string) gobreaker.Settings {
return gobreaker.Settings{
Name: name,
MaxRequests: 3,
Interval: 30 * time.Second,
Timeout: 60 * time.Second,
ReadyToTrip: func(counts gobreaker.Counts) bool {
return counts.ConsecutiveFailures > 5
},
OnStateChange: func(name string, from gobreaker.State, to gobreaker.State) {
// Log state changes for monitoring
},
}
}

View File

@@ -0,0 +1,165 @@
package components
import (
"fmt"
"math"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/sstent/aicyclingcoach-go/fitness-tui/internal/types"
)
// Chart represents an ASCII chart component
type Chart struct {
Data []float64
Title string
Width int
Height int
Color lipgloss.Color
downsampler *types.Downsampler
}
// NewChart creates a new Chart instance
func NewChart(data []float64, title string) *Chart {
return &Chart{
Data: data,
Title: title,
Width: 0, // Will be set based on terminal size
Height: 10,
Color: lipgloss.Color("39"), // Default blue
downsampler: types.NewDownsampler(),
}
}
// WithSize sets the chart dimensions
func (c *Chart) WithSize(width, height int) *Chart {
c.Width = width
c.Height = height
return c
}
// WithColor sets the chart color
func (c *Chart) WithColor(color lipgloss.Color) *Chart {
c.Color = color
return c
}
// View renders the chart
func (c *Chart) View() string {
if len(c.Data) == 0 {
return fmt.Sprintf("%s\nNo data available", c.Title)
}
// Downsample data if needed
processedData := c.downsampler.Process(c.Data, c.Width)
// Normalize data to chart height
min, max := minMax(processedData)
normalized := normalize(processedData, min, max, c.Height-1)
// Build chart
var sb strings.Builder
sb.WriteString(c.Title + "\n")
// Create Y-axis labels
yLabels := createYAxisLabels(min, max, c.Height-1)
for i := c.Height - 1; i >= 0; i-- {
if i == 0 {
sb.WriteString("└") // Bottom-left corner
} else if i == c.Height-1 {
sb.WriteString("↑") // Top axis indicator
} else {
sb.WriteString("│") // Y-axis line
}
// Add Y-axis label
if i < len(yLabels) {
sb.WriteString(yLabels[i])
} else {
sb.WriteString(" ")
}
// Add chart bars
for j := 0; j < len(normalized); j++ {
if i == 0 {
sb.WriteString("─") // X-axis
} else {
if normalized[j] >= float64(i) {
sb.WriteString("█") // Full block
} else {
// Gradient blocks based on fractional part
frac := normalized[j] - math.Floor(normalized[j])
if normalized[j] >= float64(i-1) && frac > 0.75 {
sb.WriteString("▇")
} else if normalized[j] >= float64(i-1) && frac > 0.5 {
sb.WriteString("▅")
} else if normalized[j] >= float64(i-1) && frac > 0.25 {
sb.WriteString("▃")
} else if normalized[j] >= float64(i-1) && frac > 0 {
sb.WriteString("▁")
} else {
sb.WriteString(" ")
}
}
}
}
sb.WriteString("\n")
}
// Add X-axis title
sb.WriteString(" " + strings.Repeat(" ", len(yLabels[0])+1) + "→ Time\n")
// Apply color styling
style := lipgloss.NewStyle().Foreground(c.Color)
return style.Render(sb.String())
}
// minMax finds min and max values in a slice
func minMax(data []float64) (min, max float64) {
if len(data) == 0 {
return 0, 0
}
min = data[0]
max = data[0]
for _, v := range data {
if v < min {
min = v
}
if v > max {
max = v
}
}
return min, max
}
// normalize scales values to fit within chart height
func normalize(data []float64, min, max float64, height int) []float64 {
if max == min || height <= 0 {
return make([]float64, len(data))
}
scale := float64(height) / (max - min)
normalized := make([]float64, len(data))
for i, v := range data {
normalized[i] = (v - min) * scale
}
return normalized
}
// createYAxisLabels creates labels for Y-axis
func createYAxisLabels(min, max float64, height int) []string {
labels := make([]string, height+1)
step := (max - min) / float64(height)
for i := 0; i <= height; i++ {
value := min + float64(i)*step
label := fmt.Sprintf("%.0f", value)
// Pad to consistent width (5 characters)
if len(label) < 5 {
label = strings.Repeat(" ", 5-len(label)) + label
}
labels[height-i] = label
}
return labels
}

View File

@@ -0,0 +1,63 @@
package types
// Downsampler implements the Largest-Triangle-Three-Buckets algorithm
type Downsampler struct{}
// NewDownsampler creates a new Downsampler instance
func NewDownsampler() *Downsampler {
return &Downsampler{}
}
// Process downsamples data using Largest-Triangle-Three-Buckets algorithm
func (d *Downsampler) Process(data []float64, threshold int) []float64 {
if len(data) <= threshold || threshold <= 0 {
return data
}
sampled := make([]float64, 0, threshold)
sampled = append(sampled, data[0]) // First point
bucketSize := float64(len(data)-2) / float64(threshold-2)
for i := 1; i < threshold-1; i++ {
bucketStart := int(float64(i-1)*bucketSize) + 1
bucketEnd := int(float64(i)*bucketSize) + 1
if bucketEnd >= len(data) {
bucketEnd = len(data) - 1
}
maxArea := -1.0
selectedPoint := data[bucketStart]
for j := bucketStart; j < bucketEnd; j++ {
area := triangleArea(
data[bucketStart-1],
data[j],
data[bucketEnd],
)
if area > maxArea {
maxArea = area
selectedPoint = data[j]
}
}
sampled = append(sampled, selectedPoint)
}
sampled = append(sampled, data[len(data)-1]) // Last point
return sampled
}
// triangleArea calculates the area of a triangle formed by three points
func triangleArea(a, b, c float64) float64 {
return abs((a-b)*(c-b)-(b-c)*(a-c)) / 2
}
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}