Add 'gt dashboard' CLI command (hq-s1bg) (#65)
* Add LastActivity calculation for convoy dashboard (hq-x2xy) Adds internal/activity package with color-coded activity tracking: - Green: <2 minutes (active) - Yellow: 2-5 minutes (stale) - Red: >5 minutes (stuck) Features: - Calculate() function returns Info with formatted age and color class - Helper methods: IsActive(), IsStale(), IsStuck() - Handles edge cases: zero time, future time (clock skew) Tests: 8 test functions with 25 sub-tests covering all thresholds. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add convoy dashboard HTML template with Last Activity (hq-fq1g) Adds internal/web package with convoy dashboard template: - convoy.html with Last Activity column and color coding - Green (<2min), Yellow (2-5min), Red (>5min) activity indicators - htmx auto-refresh every 30 seconds - Progress bars for convoy completion - Status indicators for open/closed convoys - Empty state when no convoys Also includes internal/activity package (dependency from hq-x2xy): - Calculate() returns Info with formatted age and color class - Helper methods: IsActive(), IsStale(), IsStuck() Tests: 6 template tests + 8 activity tests, all passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add convoy list handler with activity data (hq-3edt) Adds HTTP handler that wires convoy dashboard template to real data: - ConvoyHandler: HTTP handler for GET / rendering convoy dashboard - LiveConvoyFetcher: Fetches convoys from beads with activity data - ConvoyFetcher interface: Enables mocking for tests Features: - Fetches open convoys from town beads - Calculates progress (completed/total) from tracked issues - Gets Last Activity from worker agent beads - Color codes activity: Green (<2min), Yellow (2-5min), Red (>5min) Includes dependencies (not yet merged): - internal/activity: Activity calculation (hq-x2xy) - internal/web/templates: HTML template (hq-fq1g) Tests: 5 handler tests + 6 template tests + 8 activity tests = 19 total 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add 'gt dashboard' CLI command (hq-s1bg) Add dashboard command to start the convoy tracking web server. Usage: gt dashboard [--port=8080] [--open] Features: - --port: Configurable HTTP port (default 8080) - --open: Auto-open browser on start - Cross-platform browser launch (darwin/linux/windows) - Graceful workspace detection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
133
internal/activity/activity.go
Normal file
133
internal/activity/activity.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Package activity provides last-activity tracking and color-coding for the dashboard.
|
||||
package activity
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Color class constants for activity status.
|
||||
const (
|
||||
ColorGreen = "green" // Active: <2 minutes
|
||||
ColorYellow = "yellow" // Stale: 2-5 minutes
|
||||
ColorRed = "red" // Stuck: >5 minutes
|
||||
ColorUnknown = "unknown" // No activity data
|
||||
)
|
||||
|
||||
// Thresholds for activity color coding.
|
||||
const (
|
||||
ThresholdActive = 2 * time.Minute // Green threshold
|
||||
ThresholdStale = 5 * time.Minute // Yellow threshold (beyond this is red)
|
||||
)
|
||||
|
||||
// Info holds activity information for display.
|
||||
type Info struct {
|
||||
LastActivity time.Time // Raw timestamp of last activity
|
||||
Duration time.Duration // Time since last activity
|
||||
FormattedAge string // Human-readable age (e.g., "2m", "1h")
|
||||
ColorClass string // CSS class for coloring (green, yellow, red, unknown)
|
||||
}
|
||||
|
||||
// Calculate computes activity info from a last-activity timestamp.
|
||||
// Returns color-coded info based on thresholds:
|
||||
// - Green: <2 minutes (active)
|
||||
// - Yellow: 2-5 minutes (stale)
|
||||
// - Red: >5 minutes (stuck)
|
||||
// - Unknown: zero time value
|
||||
func Calculate(lastActivity time.Time) Info {
|
||||
info := Info{
|
||||
LastActivity: lastActivity,
|
||||
}
|
||||
|
||||
// Handle zero time (no activity data)
|
||||
if lastActivity.IsZero() {
|
||||
info.FormattedAge = "unknown"
|
||||
info.ColorClass = ColorUnknown
|
||||
return info
|
||||
}
|
||||
|
||||
// Calculate duration since last activity
|
||||
info.Duration = time.Since(lastActivity)
|
||||
|
||||
// Handle future time (clock skew) - treat as just now
|
||||
if info.Duration < 0 {
|
||||
info.Duration = 0
|
||||
}
|
||||
|
||||
// Format age string
|
||||
info.FormattedAge = formatAge(info.Duration)
|
||||
|
||||
// Determine color class
|
||||
info.ColorClass = colorForDuration(info.Duration)
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// formatAge formats a duration as a short human-readable string.
|
||||
// Examples: "<1m", "5m", "2h", "1d"
|
||||
func formatAge(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return "<1m"
|
||||
}
|
||||
if d < time.Hour {
|
||||
return formatMinutes(d)
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
return formatHours(d)
|
||||
}
|
||||
return formatDays(d)
|
||||
}
|
||||
|
||||
func formatMinutes(d time.Duration) string {
|
||||
mins := int(d.Minutes())
|
||||
return formatInt(mins) + "m"
|
||||
}
|
||||
|
||||
func formatHours(d time.Duration) string {
|
||||
hours := int(d.Hours())
|
||||
return formatInt(hours) + "h"
|
||||
}
|
||||
|
||||
func formatDays(d time.Duration) string {
|
||||
days := int(d.Hours() / 24)
|
||||
return formatInt(days) + "d"
|
||||
}
|
||||
|
||||
func formatInt(n int) string {
|
||||
if n < 10 {
|
||||
return string(rune('0'+n))
|
||||
}
|
||||
// For larger numbers, use standard conversion
|
||||
result := ""
|
||||
for n > 0 {
|
||||
result = string(rune('0'+n%10)) + result
|
||||
n /= 10
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// colorForDuration returns the color class for a given duration.
|
||||
func colorForDuration(d time.Duration) string {
|
||||
switch {
|
||||
case d < ThresholdActive:
|
||||
return ColorGreen
|
||||
case d < ThresholdStale:
|
||||
return ColorYellow
|
||||
default:
|
||||
return ColorRed
|
||||
}
|
||||
}
|
||||
|
||||
// IsActive returns true if the activity is within the active threshold (green).
|
||||
func (i Info) IsActive() bool {
|
||||
return i.ColorClass == ColorGreen
|
||||
}
|
||||
|
||||
// IsStale returns true if the activity is in the stale range (yellow).
|
||||
func (i Info) IsStale() bool {
|
||||
return i.ColorClass == ColorYellow
|
||||
}
|
||||
|
||||
// IsStuck returns true if the activity is beyond the stale threshold (red).
|
||||
func (i Info) IsStuck() bool {
|
||||
return i.ColorClass == ColorRed
|
||||
}
|
||||
178
internal/activity/activity_test.go
Normal file
178
internal/activity/activity_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCalculateActivity_Green(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
age time.Duration
|
||||
wantAge string
|
||||
wantColor string
|
||||
}{
|
||||
{"just now", 0, "<1m", ColorGreen},
|
||||
{"30 seconds", 30 * time.Second, "<1m", ColorGreen},
|
||||
{"1 minute", 1 * time.Minute, "1m", ColorGreen},
|
||||
{"1m30s", 90 * time.Second, "1m", ColorGreen},
|
||||
{"1m59s", 119 * time.Second, "1m", ColorGreen},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
lastActivity := time.Now().Add(-tt.age)
|
||||
info := Calculate(lastActivity)
|
||||
|
||||
if info.FormattedAge != tt.wantAge {
|
||||
t.Errorf("FormattedAge = %q, want %q", info.FormattedAge, tt.wantAge)
|
||||
}
|
||||
if info.ColorClass != tt.wantColor {
|
||||
t.Errorf("ColorClass = %q, want %q", info.ColorClass, tt.wantColor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateActivity_Yellow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
age time.Duration
|
||||
wantAge string
|
||||
wantColor string
|
||||
}{
|
||||
{"2 minutes", 2 * time.Minute, "2m", ColorYellow},
|
||||
{"3 minutes", 3 * time.Minute, "3m", ColorYellow},
|
||||
{"4 minutes", 4 * time.Minute, "4m", ColorYellow},
|
||||
{"4m59s", 299 * time.Second, "4m", ColorYellow},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
lastActivity := time.Now().Add(-tt.age)
|
||||
info := Calculate(lastActivity)
|
||||
|
||||
if info.FormattedAge != tt.wantAge {
|
||||
t.Errorf("FormattedAge = %q, want %q", info.FormattedAge, tt.wantAge)
|
||||
}
|
||||
if info.ColorClass != tt.wantColor {
|
||||
t.Errorf("ColorClass = %q, want %q", info.ColorClass, tt.wantColor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateActivity_Red(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
age time.Duration
|
||||
wantAge string
|
||||
wantColor string
|
||||
}{
|
||||
{"5 minutes", 5 * time.Minute, "5m", ColorRed},
|
||||
{"10 minutes", 10 * time.Minute, "10m", ColorRed},
|
||||
{"30 minutes", 30 * time.Minute, "30m", ColorRed},
|
||||
{"1 hour", 1 * time.Hour, "1h", ColorRed},
|
||||
{"2 hours", 2 * time.Hour, "2h", ColorRed},
|
||||
{"1 day", 24 * time.Hour, "1d", ColorRed},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
lastActivity := time.Now().Add(-tt.age)
|
||||
info := Calculate(lastActivity)
|
||||
|
||||
if info.FormattedAge != tt.wantAge {
|
||||
t.Errorf("FormattedAge = %q, want %q", info.FormattedAge, tt.wantAge)
|
||||
}
|
||||
if info.ColorClass != tt.wantColor {
|
||||
t.Errorf("ColorClass = %q, want %q", info.ColorClass, tt.wantColor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateActivity_ZeroTime(t *testing.T) {
|
||||
// Zero time should return unknown state
|
||||
info := Calculate(time.Time{})
|
||||
|
||||
if info.ColorClass != ColorUnknown {
|
||||
t.Errorf("ColorClass = %q, want %q for zero time", info.ColorClass, ColorUnknown)
|
||||
}
|
||||
if info.FormattedAge != "unknown" {
|
||||
t.Errorf("FormattedAge = %q, want %q for zero time", info.FormattedAge, "unknown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateActivity_FutureTime(t *testing.T) {
|
||||
// Future time (clock skew) should be treated as "just now"
|
||||
futureTime := time.Now().Add(5 * time.Second)
|
||||
info := Calculate(futureTime)
|
||||
|
||||
if info.ColorClass != ColorGreen {
|
||||
t.Errorf("ColorClass = %q, want %q for future time", info.ColorClass, ColorGreen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfo_IsActive(t *testing.T) {
|
||||
tests := []struct {
|
||||
color string
|
||||
isActive bool
|
||||
}{
|
||||
{ColorGreen, true},
|
||||
{ColorYellow, false},
|
||||
{ColorRed, false},
|
||||
{ColorUnknown, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.color, func(t *testing.T) {
|
||||
info := Info{ColorClass: tt.color}
|
||||
if info.IsActive() != tt.isActive {
|
||||
t.Errorf("IsActive() = %v, want %v for color %q", info.IsActive(), tt.isActive, tt.color)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfo_IsStale(t *testing.T) {
|
||||
tests := []struct {
|
||||
color string
|
||||
isStale bool
|
||||
}{
|
||||
{ColorGreen, false},
|
||||
{ColorYellow, true},
|
||||
{ColorRed, false},
|
||||
{ColorUnknown, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.color, func(t *testing.T) {
|
||||
info := Info{ColorClass: tt.color}
|
||||
if info.IsStale() != tt.isStale {
|
||||
t.Errorf("IsStale() = %v, want %v for color %q", info.IsStale(), tt.isStale, tt.color)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfo_IsStuck(t *testing.T) {
|
||||
tests := []struct {
|
||||
color string
|
||||
isStuck bool
|
||||
}{
|
||||
{ColorGreen, false},
|
||||
{ColorYellow, false},
|
||||
{ColorRed, true},
|
||||
{ColorUnknown, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.color, func(t *testing.T) {
|
||||
info := Info{ColorClass: tt.color}
|
||||
if info.IsStuck() != tt.isStuck {
|
||||
t.Errorf("IsStuck() = %v, want %v for color %q", info.IsStuck(), tt.isStuck, tt.color)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user