Files
gems/opal-task/internal/engine/display.go
T
joakim b02c40f716 feat: improve CLI output with relative dates, rich feedback, and recurring task info
Add relative date formatting (today, tomorrow, in 3d, etc.) for list and
detail views. Add structured feedback helpers for add/complete/delete
operations showing display IDs and parsed modifiers. Change Complete() to
return spawned recurring instance so callers can display recurrence info.
Add AppendTask to working set for immediate display ID assignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:44:56 +01:00

396 lines
9.2 KiB
Go

package engine
import (
"fmt"
"time"
"github.com/fatih/color"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
)
// FormatTaskList formats a list of tasks for display
func FormatTaskList(tasks []*Task, ws *WorkingSet) string {
return FormatTaskListWithFormat(tasks, ws, "table")
}
// FormatTaskListWithFormat formats a list of tasks with specified format
func FormatTaskListWithFormat(tasks []*Task, ws *WorkingSet, format string) string {
if len(tasks) == 0 {
return "No tasks found."
}
// Get urgency coefficients
cfg, _ := GetConfig()
coeffs := BuildUrgencyCoefficients(cfg)
// Minimal format: just ID and description, color-coded by urgency
if format == "minimal" {
result := ""
for i, task := range tasks {
displayID := i + 1
if ws != nil {
// Use working set display ID if available
for id, uuid := range ws.byID {
if uuid == task.UUID {
displayID = id
break
}
}
}
urgency := task.CalculateUrgency(coeffs)
urgencyColor := getUrgencyColor(urgency)
result += urgencyColor.Sprintf("%3d %s\n", displayID, task.Description)
}
return result
}
// Table format (default)
t := table.NewWriter()
// Configure style
t.SetStyle(table.StyleLight)
t.Style().Options.SeparateRows = false
t.Style().Options.DrawBorder = false
// Configure columns with proper widths
t.SetColumnConfigs([]table.ColumnConfig{
{Number: 1, WidthMin: 3, WidthMax: 3, Align: text.AlignRight}, // ID
{Number: 2, WidthMin: 8, WidthMax: 8, Align: text.AlignLeft}, // Status
{Number: 3, WidthMin: 4, WidthMax: 4, Align: text.AlignRight}, // Urg
{Number: 4, WidthMin: 12, WidthMax: 12, Align: text.AlignLeft}, // Project
{Number: 5, WidthMin: 40, WidthMax: 40, Align: text.AlignLeft, // Description
WidthMaxEnforcer: text.Trim},
{Number: 6, WidthMin: 12, WidthMax: 12, Align: text.AlignLeft}, // Due
{Number: 7, Align: text.AlignLeft}, // Tags (variable)
})
// Add header
t.AppendHeader(table.Row{"ID", "Status", "Urg", "Project", "Description", "Due", "Tags"})
// Add rows
for i, task := range tasks {
displayID := i + 1
if ws != nil {
// Use working set display ID if available
for id, uuid := range ws.byID {
if uuid == task.UUID {
displayID = id
break
}
}
}
urgency := task.CalculateUrgency(coeffs)
t.AppendRow(table.Row{
displayID,
formatStatus(task.Status),
formatUrgency(urgency),
formatProject(task.Project),
task.Description,
formatDue(task.Due),
formatTags(task.Tags),
})
}
return t.Render()
}
// FormatTaskDetail formats detailed task information
func FormatTaskDetail(task *Task) string {
t := table.NewWriter()
// Configure style
t.SetStyle(table.StyleLight)
t.Style().Options.SeparateRows = false
t.Style().Options.DrawBorder = false
t.Style().Options.SeparateHeader = true
// Configure columns - field name and value
t.SetColumnConfigs([]table.ColumnConfig{
{Number: 1, WidthMin: 12, WidthMax: 12, Align: text.AlignLeft},
{Number: 2, Align: text.AlignLeft},
})
// Add title as header
t.SetTitle(color.New(color.Bold).Sprint("Task Details"))
// Calculate urgency
cfg, _ := GetConfig()
coeffs := BuildUrgencyCoefficients(cfg)
urgency := task.CalculateUrgency(coeffs)
// Core fields
t.AppendRow(table.Row{"UUID", task.UUID})
t.AppendRow(table.Row{"Status", formatStatus(task.Status)})
t.AppendRow(table.Row{"Description", task.Description})
t.AppendRow(table.Row{"Urgency", formatUrgency(urgency)})
t.AppendRow(table.Row{"Priority", formatPriority(task.Priority)})
t.AppendRow(table.Row{"Project", formatProject(task.Project)})
t.AppendSeparator()
// Timestamps
t.AppendRow(table.Row{"Created", formatTime(task.Created)})
t.AppendRow(table.Row{"Modified", formatTime(task.Modified)})
if task.Start != nil {
t.AppendRow(table.Row{"Started", formatTime(*task.Start)})
}
if task.End != nil {
t.AppendRow(table.Row{"Ended", formatTime(*task.End)})
}
if task.Due != nil {
dueStr := FormatDateWithRelative(*task.Due)
if task.Due.Before(timeNow()) {
dueStr = color.RedString(dueStr)
}
t.AppendRow(table.Row{"Due", dueStr})
}
if task.Scheduled != nil {
t.AppendRow(table.Row{"Scheduled", FormatDateWithRelative(*task.Scheduled)})
}
if task.Wait != nil {
t.AppendRow(table.Row{"Wait", FormatDateWithRelative(*task.Wait)})
}
if task.Until != nil {
t.AppendRow(table.Row{"Until", FormatDateWithRelative(*task.Until)})
}
if task.RecurrenceDuration != nil {
t.AppendRow(table.Row{"Recurrence", FormatRecurrenceDuration(*task.RecurrenceDuration)})
}
if task.ParentUUID != nil {
t.AppendRow(table.Row{"Parent", fmt.Sprintf("%s (recurring instance)", *task.ParentUUID)})
}
if len(task.Tags) > 0 {
t.AppendSeparator()
t.AppendRow(table.Row{"Tags", formatTags(task.Tags)})
}
return t.Render()
}
// FormatProjects formats project list with counts
func FormatProjects(projectCounts map[string]int) string {
if len(projectCounts) == 0 {
return "No projects found."
}
t := table.NewWriter()
// Configure style
t.SetStyle(table.StyleLight)
t.Style().Options.SeparateRows = false
t.Style().Options.DrawBorder = false
// Configure columns
t.SetColumnConfigs([]table.ColumnConfig{
{Number: 1, WidthMin: 20, WidthMax: 20, Align: text.AlignLeft},
{Number: 2, Align: text.AlignRight},
})
// Add header
t.AppendHeader(table.Row{"Project", "Count"})
// Add rows
for project, count := range projectCounts {
t.AppendRow(table.Row{project, count})
}
return t.Render()
}
// FormatTagCounts formats tag list with counts
func FormatTagCounts(tagCounts map[string]int) string {
if len(tagCounts) == 0 {
return "No tags found."
}
t := table.NewWriter()
// Configure style
t.SetStyle(table.StyleLight)
t.Style().Options.SeparateRows = false
t.Style().Options.DrawBorder = false
// Configure columns
t.SetColumnConfigs([]table.ColumnConfig{
{Number: 1, WidthMin: 20, WidthMax: 20, Align: text.AlignLeft},
{Number: 2, Align: text.AlignRight},
})
// Add header
t.AppendHeader(table.Row{"Tag", "Count"})
// Add rows
for tag, count := range tagCounts {
t.AppendRow(table.Row{tag, count})
}
return t.Render()
}
// Helper functions
func formatStatus(status Status) string {
switch status {
case StatusPending:
return color.YellowString("pending")
case StatusCompleted:
return color.GreenString("done")
case StatusDeleted:
return color.RedString("deleted")
case StatusRecurring:
return color.BlueString("recurring")
default:
return "unknown"
}
}
func formatPriority(priority Priority) string {
switch priority {
case PriorityHigh:
return color.RedString("H")
case PriorityMedium:
return color.YellowString("M")
case PriorityLow:
return color.CyanString("L")
case PriorityDefault:
return "D"
default:
return "D"
}
}
func formatUrgency(urgency float64) string {
formatted := fmt.Sprintf("%.1f", urgency)
// 4-tier color coding
if urgency >= 10.0 {
// Critical urgency - bright red
return color.New(color.FgHiRed, color.Bold).Sprint(formatted)
} else if urgency >= 5.0 {
// High urgency - red
return color.RedString(formatted)
} else if urgency >= 2.0 {
// Medium urgency - yellow
return color.YellowString(formatted)
} else {
// Low urgency - cyan
return color.CyanString(formatted)
}
}
func getUrgencyColor(urgency float64) *color.Color {
// Returns color for minimal format
if urgency >= 10.0 {
return color.New(color.FgHiRed, color.Bold)
} else if urgency >= 5.0 {
return color.New(color.FgRed)
} else if urgency >= 2.0 {
return color.New(color.FgYellow)
} else {
return color.New(color.FgCyan)
}
}
func formatProject(project *string) string {
if project == nil {
return "-"
}
return *project
}
func formatDue(due *time.Time) string {
if due == nil {
return ""
}
rel := FormatRelativeDate(*due)
now := timeNow()
if due.Before(now) {
return color.RedString(rel)
}
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
tomorrow := today.Add(24 * time.Hour)
if due.Before(tomorrow) {
return color.YellowString(rel)
}
return rel
}
func formatTimeWithColor(t time.Time) string {
now := time.Now()
if t.Before(now) {
return color.RedString(t.Format("2006-01-02 15:04"))
}
return t.Format("2006-01-02 15:04")
}
func formatTime(t time.Time) string {
return t.Format("2006-01-02 15:04")
}
func formatTags(tags []string) string {
if len(tags) == 0 {
return ""
}
formatted := make([]string, len(tags))
for i, tag := range tags {
formatted[i] = color.CyanString("+" + tag)
}
// Join tags with spaces
result := formatted[0]
for i := 1; i < len(formatted); i++ {
result += " " + formatted[i]
}
return result
}
// GetProjectCounts returns a map of project names to task counts
func GetProjectCounts() (map[string]int, error) {
tasks, err := GetTasks(DefaultFilter())
if err != nil {
return nil, err
}
counts := make(map[string]int)
for _, task := range tasks {
if task.Project != nil {
counts[*task.Project]++
}
}
return counts, nil
}
// GetTagCounts returns a map of tags to task counts
func GetTagCounts() (map[string]int, error) {
tasks, err := GetTasks(DefaultFilter())
if err != nil {
return nil, err
}
counts := make(map[string]int)
for _, task := range tasks {
for _, tag := range task.Tags {
counts[tag]++
}
}
return counts, nil
}