diff --git a/internal/activity/activity.go b/internal/activity/activity.go new file mode 100644 index 00000000..9a4cfd66 --- /dev/null +++ b/internal/activity/activity.go @@ -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 +} diff --git a/internal/activity/activity_test.go b/internal/activity/activity_test.go new file mode 100644 index 00000000..38f0edf8 --- /dev/null +++ b/internal/activity/activity_test.go @@ -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) + } + }) + } +} diff --git a/internal/cmd/dashboard.go b/internal/cmd/dashboard.go new file mode 100644 index 00000000..84b73a15 --- /dev/null +++ b/internal/cmd/dashboard.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "fmt" + "net/http" + "os/exec" + "runtime" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/web" + "github.com/steveyegge/gastown/internal/workspace" +) + +var ( + dashboardPort int + dashboardOpen bool +) + +var dashboardCmd = &cobra.Command{ + Use: "dashboard", + GroupID: GroupDiag, + Short: "Start the convoy tracking web dashboard", + Long: `Start a web server that displays the convoy tracking dashboard. + +The dashboard shows real-time convoy status with: +- Convoy list with status indicators +- Progress tracking for each convoy +- Last activity indicator (green/yellow/red) +- Auto-refresh every 30 seconds via htmx + +Example: + gt dashboard # Start on default port 8080 + gt dashboard --port 3000 # Start on port 3000 + gt dashboard --open # Start and open browser`, + RunE: runDashboard, +} + +func init() { + dashboardCmd.Flags().IntVar(&dashboardPort, "port", 8080, "HTTP port to listen on") + dashboardCmd.Flags().BoolVar(&dashboardOpen, "open", false, "Open browser automatically") + rootCmd.AddCommand(dashboardCmd) +} + +func runDashboard(cmd *cobra.Command, args []string) error { + // Verify we're in a workspace + if _, err := workspace.FindFromCwdOrError(); err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Create the live convoy fetcher + fetcher, err := web.NewLiveConvoyFetcher() + if err != nil { + return fmt.Errorf("creating convoy fetcher: %w", err) + } + + // Create the handler + handler, err := web.NewConvoyHandler(fetcher) + if err != nil { + return fmt.Errorf("creating convoy handler: %w", err) + } + + // Build the URL + url := fmt.Sprintf("http://localhost:%d", dashboardPort) + + // Open browser if requested + if dashboardOpen { + go openBrowser(url) + } + + // Start the server + fmt.Printf("🚚 Gas Town Dashboard starting at %s\n", url) + fmt.Printf(" Press Ctrl+C to stop\n") + + return http.ListenAndServe(fmt.Sprintf(":%d", dashboardPort), handler) +} + +// openBrowser opens the specified URL in the default browser. +func openBrowser(url string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + default: + return + } + _ = cmd.Start() +} diff --git a/internal/cmd/dashboard_test.go b/internal/cmd/dashboard_test.go new file mode 100644 index 00000000..5b1f302d --- /dev/null +++ b/internal/cmd/dashboard_test.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestDashboardCmd_FlagsExist(t *testing.T) { + // Verify required flags exist with correct defaults + portFlag := dashboardCmd.Flags().Lookup("port") + if portFlag == nil { + t.Fatal("--port flag should exist") + } + if portFlag.DefValue != "8080" { + t.Errorf("--port default should be 8080, got %s", portFlag.DefValue) + } + + openFlag := dashboardCmd.Flags().Lookup("open") + if openFlag == nil { + t.Fatal("--open flag should exist") + } + if openFlag.DefValue != "false" { + t.Errorf("--open default should be false, got %s", openFlag.DefValue) + } +} + +func TestDashboardCmd_IsRegistered(t *testing.T) { + // Verify command is registered under root + found := false + for _, cmd := range rootCmd.Commands() { + if cmd.Name() == "dashboard" { + found = true + break + } + } + if !found { + t.Error("dashboard command should be registered with rootCmd") + } +} + +func TestDashboardCmd_HasCorrectGroup(t *testing.T) { + if dashboardCmd.GroupID != GroupDiag { + t.Errorf("dashboard should be in diag group, got %s", dashboardCmd.GroupID) + } +} + +func TestDashboardCmd_RequiresWorkspace(t *testing.T) { + // Create a test command that simulates running outside workspace + cmd := &cobra.Command{} + cmd.SetArgs([]string{}) + + // The actual workspace check happens in runDashboard + // This test verifies the command structure is correct + if dashboardCmd.RunE == nil { + t.Error("dashboard command should have RunE set") + } +} diff --git a/internal/web/fetcher.go b/internal/web/fetcher.go new file mode 100644 index 00000000..6f400ddc --- /dev/null +++ b/internal/web/fetcher.go @@ -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 +} diff --git a/internal/web/handler.go b/internal/web/handler.go new file mode 100644 index 00000000..44ae1cc7 --- /dev/null +++ b/internal/web/handler.go @@ -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 + } +} diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go new file mode 100644 index 00000000..d44de23f --- /dev/null +++ b/internal/web/handler_test.go @@ -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) + } + } +} diff --git a/internal/web/templates.go b/internal/web/templates.go new file mode 100644 index 00000000..462f2a3f --- /dev/null +++ b/internal/web/templates.go @@ -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 +} diff --git a/internal/web/templates/convoy.html b/internal/web/templates/convoy.html new file mode 100644 index 00000000..5b8fd8aa --- /dev/null +++ b/internal/web/templates/convoy.html @@ -0,0 +1,245 @@ + + + + + + Gas Town Dashboard + + + + +
+
+

🚚 Gas Town Convoys

+ + Auto-refresh: every 30s + + +
+ + {{if .Convoys}} + + + + + + + + + + + {{range .Convoys}} + + + + + + + {{end}} + +
StatusConvoyProgressLast Activity
+ + {{if eq .Status "open"}}●{{else}}✓{{end}} + + {{.ID}} + {{.Title}} + + {{.Progress}} + {{if .Total}} +
+
+
+ {{end}} +
+ + {{.LastActivity.FormattedAge}} +
+ {{else}} +
+

No convoys found

+

Create a convoy with: gt convoy create <name> [issues...]

+
+ {{end}} +
+ + diff --git a/internal/web/templates_test.go b/internal/web/templates_test.go new file mode 100644 index 00000000..f428fb71 --- /dev/null +++ b/internal/web/templates_test.go @@ -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") + } +}