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:
Mike Lady
2026-01-03 11:49:42 -08:00
committed by GitHub
parent 7afcea935b
commit 3488933cc2
10 changed files with 1567 additions and 0 deletions
+297
View File
@@ -0,0 +1,297 @@
package web
import (
"bytes"
"encoding/json"
"fmt"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/steveyegge/gastown/internal/activity"
"github.com/steveyegge/gastown/internal/workspace"
)
// LiveConvoyFetcher fetches convoy data from beads.
type LiveConvoyFetcher struct {
townBeads string
}
// NewLiveConvoyFetcher creates a fetcher for the current workspace.
func NewLiveConvoyFetcher() (*LiveConvoyFetcher, error) {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return nil, fmt.Errorf("not in a Gas Town workspace: %w", err)
}
return &LiveConvoyFetcher{
townBeads: filepath.Join(townRoot, ".beads"),
}, nil
}
// FetchConvoys fetches all open convoys with their activity data.
func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
// List all open convoy-type issues
listArgs := []string{"list", "--type=convoy", "--status=open", "--json"}
listCmd := exec.Command("bd", listArgs...)
listCmd.Dir = f.townBeads
var stdout bytes.Buffer
listCmd.Stdout = &stdout
if err := listCmd.Run(); err != nil {
return nil, fmt.Errorf("listing convoys: %w", err)
}
var convoys []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil {
return nil, fmt.Errorf("parsing convoy list: %w", err)
}
// Build convoy rows with activity data
rows := make([]ConvoyRow, 0, len(convoys))
for _, c := range convoys {
row := ConvoyRow{
ID: c.ID,
Title: c.Title,
Status: c.Status,
}
// Get tracked issues for progress and activity calculation
tracked := f.getTrackedIssues(c.ID)
row.Total = len(tracked)
var mostRecentActivity time.Time
for _, t := range tracked {
if t.Status == "closed" {
row.Completed++
}
// Track most recent activity from workers
if t.LastActivity.After(mostRecentActivity) {
mostRecentActivity = t.LastActivity
}
}
row.Progress = fmt.Sprintf("%d/%d", row.Completed, row.Total)
// Calculate activity info from most recent worker activity
if !mostRecentActivity.IsZero() {
row.LastActivity = activity.Calculate(mostRecentActivity)
} else {
row.LastActivity = activity.Info{
FormattedAge: "no activity",
ColorClass: activity.ColorUnknown,
}
}
// Get tracked issues for expandable view
row.TrackedIssues = make([]TrackedIssue, len(tracked))
for i, t := range tracked {
row.TrackedIssues[i] = TrackedIssue{
ID: t.ID,
Title: t.Title,
Status: t.Status,
Assignee: t.Assignee,
}
}
rows = append(rows, row)
}
return rows, nil
}
// trackedIssueInfo holds info about an issue being tracked by a convoy.
type trackedIssueInfo struct {
ID string
Title string
Status string
Assignee string
LastActivity time.Time
}
// getTrackedIssues fetches tracked issues for a convoy.
func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo {
dbPath := filepath.Join(f.townBeads, "beads.db")
// Query tracked dependencies from SQLite
safeConvoyID := strings.ReplaceAll(convoyID, "'", "''")
queryCmd := exec.Command("sqlite3", "-json", dbPath,
fmt.Sprintf(`SELECT depends_on_id, type FROM dependencies WHERE issue_id = '%s' AND type = 'tracks'`, safeConvoyID))
var stdout bytes.Buffer
queryCmd.Stdout = &stdout
if err := queryCmd.Run(); err != nil {
return nil
}
var deps []struct {
DependsOnID string `json:"depends_on_id"`
Type string `json:"type"`
}
if err := json.Unmarshal(stdout.Bytes(), &deps); err != nil {
return nil
}
// Collect issue IDs (normalize external refs)
issueIDs := make([]string, 0, len(deps))
for _, dep := range deps {
issueID := dep.DependsOnID
if strings.HasPrefix(issueID, "external:") {
parts := strings.SplitN(issueID, ":", 3)
if len(parts) == 3 {
issueID = parts[2]
}
}
issueIDs = append(issueIDs, issueID)
}
// Batch fetch issue details
details := f.getIssueDetailsBatch(issueIDs)
// Get worker info for activity timestamps
workers := f.getWorkersForIssues(issueIDs)
// Build result
result := make([]trackedIssueInfo, 0, len(issueIDs))
for _, id := range issueIDs {
info := trackedIssueInfo{ID: id}
if d, ok := details[id]; ok {
info.Title = d.Title
info.Status = d.Status
info.Assignee = d.Assignee
} else {
info.Title = "(external)"
info.Status = "unknown"
}
if w, ok := workers[id]; ok && w.LastActivity != nil {
info.LastActivity = *w.LastActivity
}
result = append(result, info)
}
return result
}
// issueDetail holds basic issue info.
type issueDetail struct {
ID string
Title string
Status string
Assignee string
}
// getIssueDetailsBatch fetches details for multiple issues.
func (f *LiveConvoyFetcher) getIssueDetailsBatch(issueIDs []string) map[string]*issueDetail {
result := make(map[string]*issueDetail)
if len(issueIDs) == 0 {
return result
}
args := append([]string{"show"}, issueIDs...)
args = append(args, "--json")
showCmd := exec.Command("bd", args...)
var stdout bytes.Buffer
showCmd.Stdout = &stdout
if err := showCmd.Run(); err != nil {
return result
}
var issues []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Assignee string `json:"assignee"`
}
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
return result
}
for _, issue := range issues {
result[issue.ID] = &issueDetail{
ID: issue.ID,
Title: issue.Title,
Status: issue.Status,
Assignee: issue.Assignee,
}
}
return result
}
// workerDetail holds worker info including last activity.
type workerDetail struct {
Worker string
LastActivity *time.Time
}
// getWorkersForIssues finds workers and their last activity for issues.
func (f *LiveConvoyFetcher) getWorkersForIssues(issueIDs []string) map[string]*workerDetail {
result := make(map[string]*workerDetail)
if len(issueIDs) == 0 {
return result
}
townRoot, _ := workspace.FindFromCwd()
if townRoot == "" {
return result
}
// Find all rig beads databases
rigDirs, _ := filepath.Glob(filepath.Join(townRoot, "*", "mayor", "rig", ".beads", "beads.db"))
for _, dbPath := range rigDirs {
for _, issueID := range issueIDs {
if _, ok := result[issueID]; ok {
continue
}
safeID := strings.ReplaceAll(issueID, "'", "''")
query := fmt.Sprintf(
`SELECT id, hook_bead, last_activity FROM issues WHERE issue_type = 'agent' AND status = 'open' AND hook_bead = '%s' LIMIT 1`,
safeID)
queryCmd := exec.Command("sqlite3", "-json", dbPath, query)
var stdout bytes.Buffer
queryCmd.Stdout = &stdout
if err := queryCmd.Run(); err != nil {
continue
}
var agents []struct {
ID string `json:"id"`
HookBead string `json:"hook_bead"`
LastActivity string `json:"last_activity"`
}
if err := json.Unmarshal(stdout.Bytes(), &agents); err != nil || len(agents) == 0 {
continue
}
agent := agents[0]
detail := &workerDetail{
Worker: agent.ID,
}
if agent.LastActivity != "" {
if t, err := time.Parse(time.RFC3339, agent.LastActivity); err == nil {
detail.LastActivity = &t
}
}
result[issueID] = detail
}
}
return result
}
+50
View File
@@ -0,0 +1,50 @@
package web
import (
"html/template"
"net/http"
)
// ConvoyFetcher defines the interface for fetching convoy data.
type ConvoyFetcher interface {
FetchConvoys() ([]ConvoyRow, error)
}
// ConvoyHandler handles HTTP requests for the convoy dashboard.
type ConvoyHandler struct {
fetcher ConvoyFetcher
template *template.Template
}
// NewConvoyHandler creates a new convoy handler with the given fetcher.
func NewConvoyHandler(fetcher ConvoyFetcher) (*ConvoyHandler, error) {
tmpl, err := LoadTemplates()
if err != nil {
return nil, err
}
return &ConvoyHandler{
fetcher: fetcher,
template: tmpl,
}, nil
}
// ServeHTTP handles GET / requests and renders the convoy dashboard.
func (h *ConvoyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
convoys, err := h.fetcher.FetchConvoys()
if err != nil {
http.Error(w, "Failed to fetch convoys", http.StatusInternalServerError)
return
}
data := ConvoyData{
Convoys: convoys,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.template.ExecuteTemplate(w, "convoy.html", data); err != nil {
http.Error(w, "Failed to render template", http.StatusInternalServerError)
return
}
}
+181
View File
@@ -0,0 +1,181 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/steveyegge/gastown/internal/activity"
)
// MockConvoyFetcher is a mock implementation for testing.
type MockConvoyFetcher struct {
Convoys []ConvoyRow
Error error
}
func (m *MockConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
return m.Convoys, m.Error
}
func TestConvoyHandler_RendersTemplate(t *testing.T) {
mock := &MockConvoyFetcher{
Convoys: []ConvoyRow{
{
ID: "hq-cv-abc",
Title: "Test Convoy",
Status: "open",
Progress: "2/5",
Completed: 2,
Total: 5,
LastActivity: activity.Calculate(time.Now().Add(-1 * time.Minute)),
},
},
}
handler, err := NewConvoyHandler(mock)
if err != nil {
t.Fatalf("NewConvoyHandler() error = %v", err)
}
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Status = %d, want %d", w.Code, http.StatusOK)
}
body := w.Body.String()
// Check convoy data is rendered
if !strings.Contains(body, "hq-cv-abc") {
t.Error("Response should contain convoy ID")
}
if !strings.Contains(body, "Test Convoy") {
t.Error("Response should contain convoy title")
}
if !strings.Contains(body, "2/5") {
t.Error("Response should contain progress")
}
}
func TestConvoyHandler_LastActivityColors(t *testing.T) {
tests := []struct {
name string
age time.Duration
wantClass string
}{
{"green for active", 30 * time.Second, "activity-green"},
{"yellow for stale", 3 * time.Minute, "activity-yellow"},
{"red for stuck", 10 * time.Minute, "activity-red"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := &MockConvoyFetcher{
Convoys: []ConvoyRow{
{
ID: "hq-cv-test",
Title: "Test",
Status: "open",
LastActivity: activity.Calculate(time.Now().Add(-tt.age)),
},
},
}
handler, err := NewConvoyHandler(mock)
if err != nil {
t.Fatalf("NewConvoyHandler() error = %v", err)
}
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
body := w.Body.String()
if !strings.Contains(body, tt.wantClass) {
t.Errorf("Response should contain %q", tt.wantClass)
}
})
}
}
func TestConvoyHandler_EmptyConvoys(t *testing.T) {
mock := &MockConvoyFetcher{
Convoys: []ConvoyRow{},
}
handler, err := NewConvoyHandler(mock)
if err != nil {
t.Fatalf("NewConvoyHandler() error = %v", err)
}
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Status = %d, want %d", w.Code, http.StatusOK)
}
body := w.Body.String()
if !strings.Contains(body, "No convoys") {
t.Error("Response should show empty state message")
}
}
func TestConvoyHandler_ContentType(t *testing.T) {
mock := &MockConvoyFetcher{
Convoys: []ConvoyRow{},
}
handler, err := NewConvoyHandler(mock)
if err != nil {
t.Fatalf("NewConvoyHandler() error = %v", err)
}
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "text/html") {
t.Errorf("Content-Type = %q, want text/html", contentType)
}
}
func TestConvoyHandler_MultipleConvoys(t *testing.T) {
mock := &MockConvoyFetcher{
Convoys: []ConvoyRow{
{ID: "hq-cv-1", Title: "First Convoy", Status: "open"},
{ID: "hq-cv-2", Title: "Second Convoy", Status: "closed"},
{ID: "hq-cv-3", Title: "Third Convoy", Status: "open"},
},
}
handler, err := NewConvoyHandler(mock)
if err != nil {
t.Fatalf("NewConvoyHandler() error = %v", err)
}
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
body := w.Body.String()
// Check all convoys are rendered
for _, id := range []string{"hq-cv-1", "hq-cv-2", "hq-cv-3"} {
if !strings.Contains(body, id) {
t.Errorf("Response should contain convoy %s", id)
}
}
}
+96
View File
@@ -0,0 +1,96 @@
// Package web provides HTTP server and templates for the Gas Town dashboard.
package web
import (
"embed"
"html/template"
"io/fs"
"github.com/steveyegge/gastown/internal/activity"
)
//go:embed templates/*.html
var templateFS embed.FS
// ConvoyData represents data passed to the convoy template.
type ConvoyData struct {
Convoys []ConvoyRow
}
// ConvoyRow represents a single convoy in the dashboard.
type ConvoyRow struct {
ID string
Title string
Status string // "open" or "closed"
Progress string // e.g., "2/5"
Completed int
Total int
LastActivity activity.Info
TrackedIssues []TrackedIssue
}
// TrackedIssue represents an issue tracked by a convoy.
type TrackedIssue struct {
ID string
Title string
Status string
Assignee string
}
// LoadTemplates loads and parses all HTML templates.
func LoadTemplates() (*template.Template, error) {
// Define template functions
funcMap := template.FuncMap{
"activityClass": activityClass,
"statusClass": statusClass,
"progressPercent": progressPercent,
}
// Get the templates subdirectory
subFS, err := fs.Sub(templateFS, "templates")
if err != nil {
return nil, err
}
// Parse all templates
tmpl, err := template.New("").Funcs(funcMap).ParseFS(subFS, "*.html")
if err != nil {
return nil, err
}
return tmpl, nil
}
// activityClass returns the CSS class for an activity color.
func activityClass(info activity.Info) string {
switch info.ColorClass {
case activity.ColorGreen:
return "activity-green"
case activity.ColorYellow:
return "activity-yellow"
case activity.ColorRed:
return "activity-red"
default:
return "activity-unknown"
}
}
// statusClass returns the CSS class for a convoy status.
func statusClass(status string) string {
switch status {
case "open":
return "status-open"
case "closed":
return "status-closed"
default:
return "status-unknown"
}
}
// progressPercent calculates percentage as an integer for progress bars.
func progressPercent(completed, total int) int {
if total == 0 {
return 0
}
return (completed * 100) / total
}
+245
View File
@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gas Town Dashboard</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
:root {
--bg-dark: #1a1a2e;
--bg-card: #16213e;
--text-primary: #eee;
--text-secondary: #aaa;
--border: #0f3460;
--green: #4ade80;
--yellow: #facc15;
--red: #f87171;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'SF Mono', 'Menlo', 'Monaco', monospace;
background: var(--bg-dark);
color: var(--text-primary);
padding: 20px;
min-height: 100vh;
}
.dashboard {
max-width: 1200px;
margin: 0 auto;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
h1 {
font-size: 1.5rem;
font-weight: 600;
}
.refresh-info {
color: var(--text-secondary);
font-size: 0.875rem;
}
.convoy-table {
width: 100%;
border-collapse: collapse;
background: var(--bg-card);
border-radius: 8px;
overflow: hidden;
}
.convoy-table th,
.convoy-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.convoy-table th {
background: var(--bg-dark);
font-weight: 500;
color: var(--text-secondary);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.convoy-table tr:last-child td {
border-bottom: none;
}
.convoy-table tr:hover {
background: rgba(255, 255, 255, 0.02);
}
/* Status indicators */
.status-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 8px;
}
.status-open .status-indicator {
background: var(--yellow);
}
.status-closed .status-indicator {
background: var(--green);
}
/* Activity colors */
.activity-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
}
.activity-green .activity-dot {
background: var(--green);
box-shadow: 0 0 8px var(--green);
}
.activity-yellow .activity-dot {
background: var(--yellow);
box-shadow: 0 0 8px var(--yellow);
}
.activity-red .activity-dot {
background: var(--red);
box-shadow: 0 0 8px var(--red);
}
.activity-unknown .activity-dot {
background: var(--text-secondary);
}
.convoy-id {
font-weight: 500;
color: var(--text-primary);
}
.convoy-title {
color: var(--text-secondary);
margin-left: 8px;
}
.progress {
font-variant-numeric: tabular-nums;
}
.progress-bar {
width: 60px;
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
margin-top: 4px;
}
.progress-fill {
height: 100%;
background: var(--green);
border-radius: 2px;
}
.empty-state {
text-align: center;
padding: 48px;
color: var(--text-secondary);
}
.empty-state h2 {
font-size: 1.25rem;
margin-bottom: 8px;
}
.empty-state p {
font-size: 0.875rem;
}
/* htmx loading indicator */
.htmx-request .htmx-indicator {
opacity: 1;
}
.htmx-indicator {
opacity: 0;
transition: opacity 200ms ease-in;
}
</style>
</head>
<body>
<div class="dashboard" hx-get="/" hx-trigger="every 30s" hx-swap="outerHTML">
<header>
<h1>🚚 Gas Town Convoys</h1>
<span class="refresh-info">
Auto-refresh: every 30s
<span class="htmx-indicator"></span>
</span>
</header>
{{if .Convoys}}
<table class="convoy-table">
<thead>
<tr>
<th>Status</th>
<th>Convoy</th>
<th>Progress</th>
<th>Last Activity</th>
</tr>
</thead>
<tbody>
{{range .Convoys}}
<tr class="{{statusClass .Status}}">
<td>
<span class="status-indicator"></span>
{{if eq .Status "open"}}●{{else}}✓{{end}}
</td>
<td>
<span class="convoy-id">{{.ID}}</span>
<span class="convoy-title">{{.Title}}</span>
</td>
<td class="progress">
{{.Progress}}
{{if .Total}}
<div class="progress-bar">
<div class="progress-fill" style="width: {{progressPercent .Completed .Total}}%;"></div>
</div>
{{end}}
</td>
<td class="{{activityClass .LastActivity}}">
<span class="activity-dot"></span>
{{.LastActivity.FormattedAge}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">
<h2>No convoys found</h2>
<p>Create a convoy with: gt convoy create &lt;name&gt; [issues...]</p>
</div>
{{end}}
</div>
</body>
</html>
+238
View File
@@ -0,0 +1,238 @@
package web
import (
"bytes"
"strings"
"testing"
"time"
"github.com/steveyegge/gastown/internal/activity"
)
func TestConvoyTemplate_RendersConvoyList(t *testing.T) {
tmpl, err := LoadTemplates()
if err != nil {
t.Fatalf("LoadTemplates() error = %v", err)
}
data := ConvoyData{
Convoys: []ConvoyRow{
{
ID: "hq-cv-abc",
Title: "Feature X",
Status: "open",
Progress: "2/5",
Completed: 2,
Total: 5,
LastActivity: activity.Calculate(time.Now().Add(-1 * time.Minute)),
},
{
ID: "hq-cv-def",
Title: "Bugfix Y",
Status: "open",
Progress: "1/3",
Completed: 1,
Total: 3,
LastActivity: activity.Calculate(time.Now().Add(-3 * time.Minute)),
},
},
}
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "convoy.html", data)
if err != nil {
t.Fatalf("ExecuteTemplate() error = %v", err)
}
output := buf.String()
// Check convoy IDs are rendered
if !strings.Contains(output, "hq-cv-abc") {
t.Error("Template should contain convoy ID hq-cv-abc")
}
if !strings.Contains(output, "hq-cv-def") {
t.Error("Template should contain convoy ID hq-cv-def")
}
// Check titles are rendered
if !strings.Contains(output, "Feature X") {
t.Error("Template should contain title 'Feature X'")
}
if !strings.Contains(output, "Bugfix Y") {
t.Error("Template should contain title 'Bugfix Y'")
}
}
func TestConvoyTemplate_LastActivityColors(t *testing.T) {
tmpl, err := LoadTemplates()
if err != nil {
t.Fatalf("LoadTemplates() error = %v", err)
}
tests := []struct {
name string
age time.Duration
wantClass string
}{
{"green for 1 minute", 1 * time.Minute, "activity-green"},
{"yellow for 3 minutes", 3 * time.Minute, "activity-yellow"},
{"red for 10 minutes", 10 * time.Minute, "activity-red"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := ConvoyData{
Convoys: []ConvoyRow{
{
ID: "hq-cv-test",
Title: "Test",
Status: "open",
LastActivity: activity.Calculate(time.Now().Add(-tt.age)),
},
},
}
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "convoy.html", data)
if err != nil {
t.Fatalf("ExecuteTemplate() error = %v", err)
}
output := buf.String()
if !strings.Contains(output, tt.wantClass) {
t.Errorf("Template should contain class %q for %v age", tt.wantClass, tt.age)
}
})
}
}
func TestConvoyTemplate_HtmxAutoRefresh(t *testing.T) {
tmpl, err := LoadTemplates()
if err != nil {
t.Fatalf("LoadTemplates() error = %v", err)
}
data := ConvoyData{
Convoys: []ConvoyRow{
{
ID: "hq-cv-test",
Title: "Test",
Status: "open",
},
},
}
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "convoy.html", data)
if err != nil {
t.Fatalf("ExecuteTemplate() error = %v", err)
}
output := buf.String()
// Check for htmx attributes
if !strings.Contains(output, "hx-get") {
t.Error("Template should contain hx-get for auto-refresh")
}
if !strings.Contains(output, "hx-trigger") {
t.Error("Template should contain hx-trigger for auto-refresh")
}
if !strings.Contains(output, "every 30s") {
t.Error("Template should refresh every 30 seconds")
}
}
func TestConvoyTemplate_ProgressDisplay(t *testing.T) {
tmpl, err := LoadTemplates()
if err != nil {
t.Fatalf("LoadTemplates() error = %v", err)
}
data := ConvoyData{
Convoys: []ConvoyRow{
{
ID: "hq-cv-test",
Title: "Test",
Status: "open",
Progress: "3/7",
Completed: 3,
Total: 7,
},
},
}
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "convoy.html", data)
if err != nil {
t.Fatalf("ExecuteTemplate() error = %v", err)
}
output := buf.String()
// Check progress is displayed
if !strings.Contains(output, "3/7") {
t.Error("Template should display progress '3/7'")
}
}
func TestConvoyTemplate_StatusIndicators(t *testing.T) {
tmpl, err := LoadTemplates()
if err != nil {
t.Fatalf("LoadTemplates() error = %v", err)
}
data := ConvoyData{
Convoys: []ConvoyRow{
{
ID: "hq-cv-open",
Title: "Open Convoy",
Status: "open",
},
{
ID: "hq-cv-closed",
Title: "Closed Convoy",
Status: "closed",
},
},
}
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "convoy.html", data)
if err != nil {
t.Fatalf("ExecuteTemplate() error = %v", err)
}
output := buf.String()
// Check status indicators
if !strings.Contains(output, "status-open") {
t.Error("Template should contain status-open class")
}
if !strings.Contains(output, "status-closed") {
t.Error("Template should contain status-closed class")
}
}
func TestConvoyTemplate_EmptyState(t *testing.T) {
tmpl, err := LoadTemplates()
if err != nil {
t.Fatalf("LoadTemplates() error = %v", err)
}
data := ConvoyData{
Convoys: []ConvoyRow{},
}
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "convoy.html", data)
if err != nil {
t.Fatalf("ExecuteTemplate() error = %v", err)
}
output := buf.String()
// Check for empty state message
if !strings.Contains(output, "No convoys") {
t.Error("Template should show empty state message when no convoys")
}
}