Files
gastown/internal/activity/activity_test.go
Mike Lady 3488933cc2 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>
2026-01-03 11:49:42 -08:00

179 lines
4.4 KiB
Go

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