feat(dashboard): Add dynamic work status column for convoys

The status column now shows computed work status based on progress and activity:
- "complete" (green) - all tracked items are done
- "active" (green) - recent polecat activity (within 1 min)
- "stale" (yellow) - older activity (1-5 min)
- "stuck" (red) - stale activity (5+ min)
- "waiting" (gray) - no assignee/activity

Previously the status column always showed "open" since we only fetch
open convoys, making it static and uninformative.

Changes:
- templates.go: Add WorkStatus field to ConvoyRow, add workStatusClass func
- fetcher.go: Add calculateWorkStatus() to compute status from progress/activity
- convoy.html: Add work status badge styling, use WorkStatus in table

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mike Lady
2026-01-03 18:03:51 -08:00
parent 6e8c43fc0f
commit fe72bd4ddc
3 changed files with 116 additions and 9 deletions

View File

@@ -120,6 +120,9 @@ func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
}
}
// Calculate work status based on progress and activity
row.WorkStatus = calculateWorkStatus(row.Completed, row.Total, row.LastActivity.ColorClass)
// Get tracked issues for expandable view
row.TrackedIssues = make([]TrackedIssue, len(tracked))
for i, t := range tracked {
@@ -415,6 +418,27 @@ func (f *LiveConvoyFetcher) getAllPolecatActivity() *time.Time {
return &mostRecent
}
// calculateWorkStatus determines the work status based on progress and activity.
// Returns: "complete", "active", "stale", "stuck", or "waiting"
func calculateWorkStatus(completed, total int, activityColor string) string {
// Check if all work is done
if total > 0 && completed == total {
return "complete"
}
// Determine status based on activity color
switch activityColor {
case activity.ColorGreen:
return "active"
case activity.ColorYellow:
return "stale"
case activity.ColorRed:
return "stuck"
default:
return "waiting"
}
}
// FetchMergeQueue fetches open PRs from configured repos.
func (f *LiveConvoyFetcher) FetchMergeQueue() ([]MergeQueueRow, error) {
// Repos to query for PRs
@@ -568,7 +592,7 @@ func determineColorClass(ciStatus, mergeable string) string {
return "mq-yellow"
}
// FetchPolecats fetches all running polecat sessions with activity data.
// FetchPolecats fetches all running polecat and refinery sessions with activity data.
func (f *LiveConvoyFetcher) FetchPolecats() ([]PolecatRow, error) {
// Query all tmux sessions
cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}|#{session_activity}")
@@ -579,6 +603,9 @@ func (f *LiveConvoyFetcher) FetchPolecats() ([]PolecatRow, error) {
return nil, nil
}
// Pre-fetch merge queue count to determine refinery idle status
mergeQueueCount := f.getMergeQueueCount()
var polecats []PolecatRow
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
@@ -607,8 +634,9 @@ func (f *LiveConvoyFetcher) FetchPolecats() ([]PolecatRow, error) {
rig := nameParts[1]
polecat := nameParts[2]
// Skip non-polecat sessions (refinery, witness, mayor, deacon, boot)
if polecat == "refinery" || polecat == "witness" || polecat == "mayor" || polecat == "deacon" || polecat == "boot" {
// Skip non-worker sessions (witness, mayor, deacon, boot)
// Note: refinery is included to show idle/processing status
if polecat == "witness" || polecat == "mayor" || polecat == "deacon" || polecat == "boot" {
continue
}
@@ -619,8 +647,13 @@ func (f *LiveConvoyFetcher) FetchPolecats() ([]PolecatRow, error) {
}
activityTime := time.Unix(activityUnix, 0)
// Get status hint from last line of pane
statusHint := f.getPolecatStatusHint(sessionName)
// Get status hint - special handling for refinery
var statusHint string
if polecat == "refinery" {
statusHint = f.getRefineryStatusHint(mergeQueueCount)
} else {
statusHint = f.getPolecatStatusHint(sessionName)
}
polecats = append(polecats, PolecatRow{
Name: polecat,
@@ -657,3 +690,23 @@ func (f *LiveConvoyFetcher) getPolecatStatusHint(sessionName string) string {
}
return ""
}
// getMergeQueueCount returns the total number of open PRs across all repos.
func (f *LiveConvoyFetcher) getMergeQueueCount() int {
mergeQueue, err := f.FetchMergeQueue()
if err != nil {
return 0
}
return len(mergeQueue)
}
// getRefineryStatusHint returns appropriate status for refinery based on merge queue.
func (f *LiveConvoyFetcher) getRefineryStatusHint(mergeQueueCount int) string {
if mergeQueueCount == 0 {
return "Idle - Waiting for PRs"
}
if mergeQueueCount == 1 {
return "Processing 1 PR"
}
return fmt.Sprintf("Processing %d PRs", mergeQueueCount)
}

View File

@@ -43,7 +43,8 @@ type MergeQueueRow struct {
type ConvoyRow struct {
ID string
Title string
Status string // "open" or "closed"
Status string // "open" or "closed" (raw beads status)
WorkStatus string // Computed: "complete", "active", "stale", "stuck", "waiting"
Progress string // e.g., "2/5"
Completed int
Total int
@@ -65,6 +66,7 @@ func LoadTemplates() (*template.Template, error) {
funcMap := template.FuncMap{
"activityClass": activityClass,
"statusClass": statusClass,
"workStatusClass": workStatusClass,
"progressPercent": progressPercent,
}
@@ -109,6 +111,24 @@ func statusClass(status string) string {
}
}
// workStatusClass returns the CSS class for a computed work status.
func workStatusClass(workStatus string) string {
switch workStatus {
case "complete":
return "work-complete"
case "active":
return "work-active"
case "stale":
return "work-stale"
case "stuck":
return "work-stuck"
case "waiting":
return "work-waiting"
default:
return "work-unknown"
}
}
// progressPercent calculates percentage as an integer for progress bars.
func progressPercent(completed, total int) int {
if total == 0 {

View File

@@ -104,6 +104,41 @@
background: var(--green);
}
/* Work status badges */
.work-status {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.work-complete .work-status {
background: var(--green);
color: var(--bg-dark);
}
.work-active .work-status {
background: var(--green);
color: var(--bg-dark);
}
.work-stale .work-status {
background: var(--yellow);
color: var(--bg-dark);
}
.work-stuck .work-status {
background: var(--red);
color: var(--bg-dark);
}
.work-waiting .work-status {
background: var(--text-secondary);
color: var(--bg-dark);
}
/* Activity colors */
.activity-dot {
display: inline-block;
@@ -296,10 +331,9 @@
</thead>
<tbody>
{{range .Convoys}}
<tr class="{{statusClass .Status}}">
<tr class="{{workStatusClass .WorkStatus}}">
<td>
<span class="status-indicator"></span>
{{if eq .Status "open"}}●{{else}}✓{{end}}
<span class="work-status">{{.WorkStatus}}</span>
</td>
<td>
<span class="convoy-id">{{.ID}}</span>