Files
gems/opal-task/internal/engine/display.go
T
joakim 32cc05a546 feat: add task history via log command and info integration
Add engine/history.go with GetTaskHistory and diff-style FormatTaskHistory
that compares consecutive change_log entries to show only what changed.
Add cmd/log.go command for full task history. Integrate last 5 history
entries into FormatTaskDetail (info view) as a "Recent Changes" section.

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

428 lines
10 KiB
Go

package engine
import (
"fmt"
"strings"
"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)})
}
if len(task.Annotations) > 0 {
t.AppendSeparator()
for i, ann := range task.Annotations {
label := ""
if i == 0 {
label = "Annotations"
}
ts := time.Unix(ann.Timestamp, 0).Format("2006-01-02 15:04")
t.AppendRow(table.Row{label, fmt.Sprintf("%s %s", ts, ann.Text)})
}
}
// Recent changes from change_log (last 5)
if entries, err := GetTaskHistory(task.UUID); err == nil && len(entries) > 0 {
t.AppendSeparator()
// Show last 5 entries
start := 0
if len(entries) > 5 {
start = len(entries) - 5
}
historyStr := FormatTaskHistory(entries[start:])
lines := strings.Split(strings.TrimSpace(historyStr), "\n")
for i, line := range lines {
label := ""
if i == 0 {
label = "History"
}
t.AppendRow(table.Row{label, line})
}
}
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
}