Skip to content

Commit dd1e1f9

Browse files
Merge pull request #5 from edouard-claude/feat/enhanced-gain-dashboard
feat: enhanced gain dashboard
2 parents 457f725 + 7aabb37 commit dd1e1f9

File tree

8 files changed

+702
-38
lines changed

8 files changed

+702
-38
lines changed

internal/cli/cli.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,10 @@ Examples:
163163
snip git log -10
164164
snip go test ./...
165165
snip gain --daily
166+
snip gain --weekly
167+
snip gain --monthly
168+
snip gain --top 10
169+
snip gain --history 20
166170
snip init
167171
`
168172
fmt.Printf(usage, version)

internal/display/display.go

Lines changed: 139 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ var (
1616
DimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
1717
StatStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("14"))
1818
WarnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11"))
19+
GreenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
20+
YellowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11"))
21+
RedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
1922
)
2023

2124
// IsTerminal returns true if stdout is a TTY.
@@ -45,21 +48,151 @@ func FormatSeparator(width int) string {
4548
return strings.Repeat("═", width)
4649
}
4750

51+
// ColorSavings returns a savings percentage string colored by tier.
52+
// ≥70% green, 30-70% yellow, <30% red.
53+
func ColorSavings(pct float64) string {
54+
text := fmt.Sprintf("%.1f%%", pct)
55+
if !IsTerminal() {
56+
return text
57+
}
58+
switch {
59+
case pct >= 70:
60+
return GreenStyle.Render(text)
61+
case pct >= 30:
62+
return YellowStyle.Render(text)
63+
default:
64+
return RedStyle.Render(text)
65+
}
66+
}
67+
68+
// TierLabel returns an efficiency tier label based on savings percentage.
69+
func TierLabel(pct float64) string {
70+
switch {
71+
case pct >= 90:
72+
return "Elite"
73+
case pct >= 70:
74+
return "Great"
75+
case pct >= 50:
76+
return "Good"
77+
case pct >= 30:
78+
return "Fair"
79+
default:
80+
return "Low"
81+
}
82+
}
83+
84+
// ColorTier returns a tier label colored by level.
85+
func ColorTier(tier string) string {
86+
if !IsTerminal() {
87+
return tier
88+
}
89+
switch tier {
90+
case "Elite":
91+
return GreenStyle.Bold(true).Render(tier)
92+
case "Great":
93+
return GreenStyle.Render(tier)
94+
case "Good":
95+
return YellowStyle.Render(tier)
96+
case "Fair":
97+
return WarnStyle.Render(tier)
98+
default:
99+
return RedStyle.Render(tier)
100+
}
101+
}
102+
103+
// ColorBar returns a colored impact bar (green filled, dim empty).
104+
// Guarantees at least 1 filled block when value > 0.
105+
func ColorBar(value, maxVal, width int) string {
106+
if maxVal <= 0 || width <= 0 {
107+
return strings.Repeat("░", width)
108+
}
109+
filled := min(max(value*width/maxVal, 0), width)
110+
if filled == 0 && value > 0 {
111+
filled = 1
112+
}
113+
filledStr := strings.Repeat("█", filled)
114+
emptyStr := strings.Repeat("░", width-filled)
115+
if !IsTerminal() {
116+
return filledStr + emptyStr
117+
}
118+
return GreenStyle.Render(filledStr) + DimStyle.Render(emptyStr)
119+
}
120+
121+
// FormatBar renders a horizontal bar proportional to value/maxVal.
122+
func FormatBar(value, maxVal, width int) string {
123+
if maxVal <= 0 || width <= 0 {
124+
return strings.Repeat("░", width)
125+
}
126+
filled := min(max(value*width/maxVal, 0), width)
127+
return strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
128+
}
129+
130+
// FormatSparkline renders a sparkline from a slice of values.
131+
// Uses Unicode block characters ▁▂▃▄▅▆▇█.
132+
func FormatSparkline(values []float64) string {
133+
blocks := []rune("▁▂▃▄▅▆▇█")
134+
135+
if len(values) == 0 {
136+
return ""
137+
}
138+
139+
max := values[0]
140+
for _, v := range values[1:] {
141+
if v > max {
142+
max = v
143+
}
144+
}
145+
146+
var b strings.Builder
147+
for _, v := range values {
148+
idx := 0
149+
if max > 0 {
150+
idx = int(v / max * float64(len(blocks)-1))
151+
}
152+
if idx >= len(blocks) {
153+
idx = len(blocks) - 1
154+
}
155+
if idx < 0 {
156+
idx = 0
157+
}
158+
b.WriteRune(blocks[idx])
159+
}
160+
return b.String()
161+
}
162+
163+
// visualWidth returns the visible width of a string, ignoring ANSI escape codes.
164+
func visualWidth(s string) int {
165+
return lipgloss.Width(s)
166+
}
167+
168+
// padRight pads a string to the target visual width with spaces,
169+
// correctly handling ANSI escape codes.
170+
func padRight(s string, targetWidth int) string {
171+
vw := visualWidth(s)
172+
if vw >= targetWidth {
173+
return s
174+
}
175+
return s + strings.Repeat(" ", targetWidth-vw)
176+
}
177+
48178
// FormatTable formats data as a simple aligned table.
179+
// Handles ANSI-colored cells correctly for alignment.
49180
func FormatTable(headers []string, rows [][]string) string {
50181
if len(headers) == 0 {
51182
return ""
52183
}
53184

54-
// Calculate column widths
185+
// Calculate column widths using visual width (ANSI-safe)
55186
widths := make([]int, len(headers))
56187
for i, h := range headers {
57-
widths[i] = len(h)
188+
widths[i] = visualWidth(h)
58189
}
59190
for _, row := range rows {
60191
for i, cell := range row {
61-
if i < len(widths) && len(cell) > widths[i] {
62-
widths[i] = len(cell)
192+
if i < len(widths) {
193+
if w := visualWidth(cell); w > widths[i] {
194+
widths[i] = w
195+
}
63196
}
64197
}
65198
}
@@ -71,7 +204,7 @@ func FormatTable(headers []string, rows [][]string) string {
71204
if i > 0 {
72205
b.WriteString(" ")
73206
}
74-
fmt.Fprintf(&b, "%-*s", widths[i], h)
207+
b.WriteString(padRight(h, widths[i]))
75208
}
76209
b.WriteString("\n")
77210

@@ -91,7 +224,7 @@ func FormatTable(headers []string, rows [][]string) string {
91224
b.WriteString(" ")
92225
}
93226
if i < len(widths) {
94-
fmt.Fprintf(&b, "%-*s", widths[i], cell)
227+
b.WriteString(padRight(cell, widths[i]))
95228
}
96229
}
97230
b.WriteString("\n")

internal/display/display_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,84 @@ func TestFormatTableEmpty(t *testing.T) {
3838
t.Errorf("expected empty, got %q", result)
3939
}
4040
}
41+
42+
func TestFormatBar(t *testing.T) {
43+
tests := []struct {
44+
name string
45+
value int
46+
maxVal int
47+
width int
48+
wantLen int
49+
wantFull bool
50+
}{
51+
{"full bar", 100, 100, 10, 10, true},
52+
{"half bar", 50, 100, 10, 10, false},
53+
{"empty bar", 0, 100, 10, 10, false},
54+
{"zero max", 50, 0, 10, 10, false},
55+
}
56+
57+
for _, tt := range tests {
58+
t.Run(tt.name, func(t *testing.T) {
59+
bar := FormatBar(tt.value, tt.maxVal, tt.width)
60+
runes := []rune(bar)
61+
if len(runes) != tt.wantLen {
62+
t.Errorf("bar rune len = %d, want %d", len(runes), tt.wantLen)
63+
}
64+
if tt.wantFull {
65+
for _, r := range runes {
66+
if r != '█' {
67+
t.Errorf("expected all filled, got %c", r)
68+
break
69+
}
70+
}
71+
}
72+
})
73+
}
74+
}
75+
76+
func TestFormatSparkline(t *testing.T) {
77+
values := []float64{10, 50, 80, 30, 100}
78+
spark := FormatSparkline(values)
79+
runes := []rune(spark)
80+
if len(runes) != 5 {
81+
t.Errorf("sparkline len = %d, want 5", len(runes))
82+
}
83+
// Last value is max (100), should be highest block
84+
if runes[4] != '█' {
85+
t.Errorf("max value should be █, got %c", runes[4])
86+
}
87+
}
88+
89+
func TestFormatSparklineEmpty(t *testing.T) {
90+
spark := FormatSparkline(nil)
91+
if spark != "" {
92+
t.Errorf("expected empty, got %q", spark)
93+
}
94+
}
95+
96+
func TestTierLabel(t *testing.T) {
97+
tests := []struct {
98+
pct float64
99+
want string
100+
}{
101+
{95, "Elite"},
102+
{75, "Great"},
103+
{55, "Good"},
104+
{35, "Fair"},
105+
{10, "Low"},
106+
}
107+
for _, tt := range tests {
108+
got := TierLabel(tt.pct)
109+
if got != tt.want {
110+
t.Errorf("TierLabel(%.0f) = %q, want %q", tt.pct, got, tt.want)
111+
}
112+
}
113+
}
114+
115+
func TestColorSavingsNonTTY(t *testing.T) {
116+
// Non-TTY: should return plain text (no ANSI codes)
117+
result := ColorSavings(85.3)
118+
if !strings.Contains(result, "85.3%") {
119+
t.Errorf("expected 85.3%%, got %q", result)
120+
}
121+
}

0 commit comments

Comments
 (0)