diff --git a/opal-task/cmd/root.go b/opal-task/cmd/root.go index 7506c09..be00970 100644 --- a/opal-task/cmd/root.go +++ b/opal-task/cmd/root.go @@ -31,7 +31,7 @@ var commandNames = []string{ // Report names (dynamically populated) var reportNames = []string{ "active", "all", "completed", "list", "minimal", - "newest", "oldest", "overdue", "ready", "recurring", + "newest", "next", "oldest", "overdue", "ready", "recurring", "template", "waiting", } diff --git a/opal-task/internal/engine/config.go b/opal-task/internal/engine/config.go index 2ed4422..49a02e5 100644 --- a/opal-task/internal/engine/config.go +++ b/opal-task/internal/engine/config.go @@ -18,6 +18,22 @@ type Config struct { WeekStartDay string `mapstructure:"week_start_day"` DefaultDueTime string `mapstructure:"default_due_time"` + // Urgency coefficients + UrgencyDue float64 `mapstructure:"urgency_due_coefficient"` + UrgencyPriorityH float64 `mapstructure:"urgency_priority_h_coefficient"` + UrgencyPriorityM float64 `mapstructure:"urgency_priority_m_coefficient"` + UrgencyPriorityD float64 `mapstructure:"urgency_priority_d_coefficient"` + UrgencyPriorityL float64 `mapstructure:"urgency_priority_l_coefficient"` + UrgencyActive float64 `mapstructure:"urgency_active_coefficient"` + UrgencyAge float64 `mapstructure:"urgency_age_coefficient"` + UrgencyAgeMax int `mapstructure:"urgency_age_max"` + UrgencyTags float64 `mapstructure:"urgency_tags_coefficient"` + UrgencyProject float64 `mapstructure:"urgency_project_coefficient"` + UrgencyWaiting float64 `mapstructure:"urgency_waiting_coefficient"` + UrgencyUrgentTag string `mapstructure:"urgency_urgent_tag"` + UrgencyUrgentCoeff float64 `mapstructure:"urgency_urgent_coefficient"` + NextLimit int `mapstructure:"next_limit"` + // Sync settings SyncEnabled bool `mapstructure:"sync_enabled"` SyncURL string `mapstructure:"sync_url"` @@ -89,6 +105,22 @@ func LoadConfig() (*Config, error) { v.SetDefault("week_start_day", "monday") v.SetDefault("default_due_time", "") + // Urgency defaults (adjusted for Opal's simpler model) + v.SetDefault("urgency_due_coefficient", 12.0) + v.SetDefault("urgency_priority_h_coefficient", 6.0) + v.SetDefault("urgency_priority_m_coefficient", 3.9) + v.SetDefault("urgency_priority_d_coefficient", 1.8) + v.SetDefault("urgency_priority_l_coefficient", 0.0) + v.SetDefault("urgency_active_coefficient", 4.0) + v.SetDefault("urgency_age_coefficient", 2.0) + v.SetDefault("urgency_age_max", 365) + v.SetDefault("urgency_tags_coefficient", 1.0) + v.SetDefault("urgency_project_coefficient", 1.0) + v.SetDefault("urgency_waiting_coefficient", -3.0) + v.SetDefault("urgency_urgent_tag", "next") + v.SetDefault("urgency_urgent_coefficient", 15.0) + v.SetDefault("next_limit", 5) + // Sync defaults v.SetDefault("sync_enabled", false) v.SetDefault("sync_url", "") @@ -138,6 +170,22 @@ func SaveConfig(cfg *Config) error { v.Set("week_start_day", cfg.WeekStartDay) v.Set("default_due_time", cfg.DefaultDueTime) + // Urgency settings + v.Set("urgency_due_coefficient", cfg.UrgencyDue) + v.Set("urgency_priority_h_coefficient", cfg.UrgencyPriorityH) + v.Set("urgency_priority_m_coefficient", cfg.UrgencyPriorityM) + v.Set("urgency_priority_d_coefficient", cfg.UrgencyPriorityD) + v.Set("urgency_priority_l_coefficient", cfg.UrgencyPriorityL) + v.Set("urgency_active_coefficient", cfg.UrgencyActive) + v.Set("urgency_age_coefficient", cfg.UrgencyAge) + v.Set("urgency_age_max", cfg.UrgencyAgeMax) + v.Set("urgency_tags_coefficient", cfg.UrgencyTags) + v.Set("urgency_project_coefficient", cfg.UrgencyProject) + v.Set("urgency_waiting_coefficient", cfg.UrgencyWaiting) + v.Set("urgency_urgent_tag", cfg.UrgencyUrgentTag) + v.Set("urgency_urgent_coefficient", cfg.UrgencyUrgentCoeff) + v.Set("next_limit", cfg.NextLimit) + // Sync settings v.Set("sync_enabled", cfg.SyncEnabled) v.Set("sync_url", cfg.SyncURL) diff --git a/opal-task/internal/engine/display.go b/opal-task/internal/engine/display.go index 72d3231..e902257 100644 --- a/opal-task/internal/engine/display.go +++ b/opal-task/internal/engine/display.go @@ -20,7 +20,11 @@ func FormatTaskListWithFormat(tasks []*Task, ws *WorkingSet, format string) stri return "No tasks found." } - // Minimal format: just ID and description + // 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 { @@ -34,7 +38,9 @@ func FormatTaskListWithFormat(tasks []*Task, ws *WorkingSet, format string) stri } } } - result += fmt.Sprintf("%3d %s\n", displayID, task.Description) + urgency := task.CalculateUrgency(coeffs) + urgencyColor := getUrgencyColor(urgency) + result += urgencyColor.Sprintf("%3d %s\n", displayID, task.Description) } return result } @@ -51,7 +57,7 @@ func FormatTaskListWithFormat(tasks []*Task, ws *WorkingSet, format string) stri 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: 3, WidthMax: 3, Align: text.AlignCenter}, // Pri + {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}, @@ -60,7 +66,7 @@ func FormatTaskListWithFormat(tasks []*Task, ws *WorkingSet, format string) stri }) // Add header - t.AppendHeader(table.Row{"ID", "Status", "Pri", "Project", "Description", "Due", "Tags"}) + t.AppendHeader(table.Row{"ID", "Status", "Urg", "Project", "Description", "Due", "Tags"}) // Add rows for i, task := range tasks { @@ -75,10 +81,11 @@ func FormatTaskListWithFormat(tasks []*Task, ws *WorkingSet, format string) stri } } + urgency := task.CalculateUrgency(coeffs) t.AppendRow(table.Row{ displayID, formatStatus(task.Status), - formatPriority(task.Priority), + formatUrgency(urgency), formatProject(task.Project), task.Description, formatDue(task.Due), @@ -108,10 +115,16 @@ func FormatTaskDetail(task *Task) string { // 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)}) @@ -253,6 +266,38 @@ func formatPriority(priority Priority) string { } } +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 "-" diff --git a/opal-task/internal/engine/report.go b/opal-task/internal/engine/report.go index 2393aec..d4d20eb 100644 --- a/opal-task/internal/engine/report.go +++ b/opal-task/internal/engine/report.go @@ -31,6 +31,7 @@ func AllReports() map[string]*Report { "list": ListReport(), "minimal": MinimalReport(), "newest": NewestReport(), + "next": NextReport(), "oldest": OldestReport(), "overdue": OverdueReport(), "ready": ReadyReport(), @@ -61,6 +62,7 @@ func ActiveReport() *Report { Description: "Started tasks", BaseFilter: filter, DisplayFormat: DisplayFormatTable, + SortFunc: sortByUrgency, } } @@ -100,6 +102,7 @@ func ListReport() *Report { Description: "Pending tasks", BaseFilter: filter, DisplayFormat: DisplayFormatTable, + SortFunc: sortByUrgency, } } @@ -113,6 +116,7 @@ func MinimalReport() *Report { Description: "Pending tasks (minimal format)", BaseFilter: filter, DisplayFormat: DisplayFormatMinimal, + SortFunc: sortByUrgency, } } @@ -148,6 +152,51 @@ func NewestReport() *Report { } } +// NextReport shows most urgent tasks ready to work on +func NextReport() *Report { + filter := NewFilter() + filter.Attributes["status"] = "pending" + filter.Attributes["_ready"] = "true" + + return &Report{ + Name: "next", + Description: "Most urgent tasks ready to work on", + BaseFilter: filter, + DisplayFormat: DisplayFormatTable, + SortFunc: func(tasks []*Task) []*Task { + // Sort by urgency descending + cfg, _ := GetConfig() + coeffs := BuildUrgencyCoefficients(cfg) + + sorted := make([]*Task, len(tasks)) + copy(sorted, tasks) + + for i := 0; i < len(sorted)-1; i++ { + for j := i + 1; j < len(sorted); j++ { + urgI := sorted[i].CalculateUrgency(coeffs) + urgJ := sorted[j].CalculateUrgency(coeffs) + if urgI < urgJ { + sorted[i], sorted[j] = sorted[j], sorted[i] + } + } + } + + return sorted + }, + LimitFunc: func(tasks []*Task) []*Task { + cfg, _ := GetConfig() + limit := cfg.NextLimit + if limit <= 0 { + limit = 5 + } + if len(tasks) > limit { + return tasks[:limit] + } + return tasks + }, + } +} + // OldestReport shows oldest pending tasks func OldestReport() *Report { filter := NewFilter() @@ -185,6 +234,7 @@ func OverdueReport() *Report { Description: "Overdue tasks", BaseFilter: filter, DisplayFormat: DisplayFormatTable, + SortFunc: sortByUrgency, } } @@ -199,6 +249,7 @@ func ReadyReport() *Report { Description: "Tasks ready to work on", BaseFilter: filter, DisplayFormat: DisplayFormatTable, + SortFunc: sortByUrgency, } } @@ -368,3 +419,24 @@ func mergeFilters(base, user *Filter) *Filter { return merged } + +// sortByUrgency is a helper function to sort tasks by urgency (descending) +func sortByUrgency(tasks []*Task) []*Task { + cfg, _ := GetConfig() + coeffs := BuildUrgencyCoefficients(cfg) + + sorted := make([]*Task, len(tasks)) + copy(sorted, tasks) + + for i := 0; i < len(sorted)-1; i++ { + for j := i + 1; j < len(sorted); j++ { + urgI := sorted[i].CalculateUrgency(coeffs) + urgJ := sorted[j].CalculateUrgency(coeffs) + if urgI < urgJ { + sorted[i], sorted[j] = sorted[j], sorted[i] + } + } + } + + return sorted +} diff --git a/opal-task/internal/engine/urgency.go b/opal-task/internal/engine/urgency.go new file mode 100644 index 0000000..6bbb0f6 --- /dev/null +++ b/opal-task/internal/engine/urgency.go @@ -0,0 +1,190 @@ +package engine + +// UrgencyCoefficients holds the configurable coefficients for urgency calculation +type UrgencyCoefficients struct { + Due float64 + PriorityH float64 + PriorityM float64 + PriorityD float64 + PriorityL float64 + Active float64 + Age float64 + AgeMax int + Tags float64 + Project float64 + Waiting float64 + UrgentTag string // Configurable urgent tag name (default: "next") + UrgentCoeff float64 // Coefficient for urgent tag +} + +// CalculateUrgency computes the urgency score for a task +func (t *Task) CalculateUrgency(coeffs *UrgencyCoefficients) float64 { + urgency := 0.0 + + // Due date contribution + urgency += t.urgencyDue(coeffs.Due) + + // Priority contribution + urgency += t.urgencyPriority(coeffs) + + // Age contribution + urgency += t.urgencyAge(coeffs.Age, coeffs.AgeMax) + + // Active task contribution + urgency += t.urgencyActive(coeffs.Active) + + // Waiting task penalty + urgency += t.urgencyWaiting(coeffs.Waiting) + + // Tags contribution + urgency += t.urgencyTags(coeffs) + + // Project contribution + urgency += t.urgencyProject(coeffs.Project) + + return urgency +} + +// urgencyDue calculates urgency contribution from due date +// Linear scale: overdue=12.0, today=10.0, 7 days=6.0, 14+ days=2.0 +func (t *Task) urgencyDue(coeff float64) float64 { + if t.Due == nil { + return 0.0 + } + + now := timeNow() + due := *t.Due + + // Calculate days until due (negative if overdue) + duration := due.Sub(now) + daysUntil := duration.Hours() / 24.0 + + var scale float64 + + if daysUntil < 0 { + // Overdue - maximum urgency + scale = 12.0 + } else if daysUntil < 1 { + // Due today + scale = 10.0 + } else if daysUntil <= 7 { + // Due within a week - linear from 10.0 to 6.0 + scale = 10.0 - (daysUntil * 0.571) // (10-6)/7 = 0.571 + } else if daysUntil <= 14 { + // Due within two weeks - linear from 6.0 to 2.0 + scale = 6.0 - ((daysUntil - 7.0) * 0.571) // (6-2)/7 = 0.571 + } else { + // Due further out - low urgency + scale = 2.0 + } + + return scale * coeff +} + +// urgencyPriority calculates urgency contribution from priority +// Order: High > Medium > Default > Low +func (t *Task) urgencyPriority(coeffs *UrgencyCoefficients) float64 { + switch t.Priority { + case PriorityHigh: + return coeffs.PriorityH + case PriorityMedium: + return coeffs.PriorityM + case PriorityDefault: + return coeffs.PriorityD + case PriorityLow: + return coeffs.PriorityL + default: + return 0.0 + } +} + +// urgencyAge calculates urgency contribution from task age +// Age increases linearly up to maxDays, then caps +func (t *Task) urgencyAge(coeff float64, maxDays int) float64 { + now := timeNow() + ageDuration := now.Sub(t.Created) + ageDays := int(ageDuration.Hours() / 24.0) + + if ageDays > maxDays { + ageDays = maxDays + } + + ageRatio := float64(ageDays) / float64(maxDays) + return ageRatio * coeff +} + +// urgencyActive calculates urgency contribution for started tasks +func (t *Task) urgencyActive(coeff float64) float64 { + if t.Start != nil { + return coeff + } + return 0.0 +} + +// urgencyWaiting calculates urgency penalty for waiting tasks +func (t *Task) urgencyWaiting(coeff float64) float64 { + if t.Wait != nil { + now := timeNow() + if t.Wait.After(now) { + return coeff // Should be negative coefficient + } + } + return 0.0 +} + +// urgencyTags calculates urgency contribution from tags +// Special urgent tag (configurable, default "next") has high coefficient +// Regular tags contribute with diminishing returns (0.8 for 1, 0.9 for 2, 1.0 for 3+) +func (t *Task) urgencyTags(coeffs *UrgencyCoefficients) float64 { + tagCount := len(t.Tags) + if tagCount == 0 { + return 0.0 + } + + // Check for special urgent tag first + for _, tag := range t.Tags { + if tag == coeffs.UrgentTag { + return coeffs.UrgentCoeff + } + } + + // Apply general tag coefficient with multiplier based on count + var multiplier float64 + switch tagCount { + case 1: + multiplier = 0.8 + case 2: + multiplier = 0.9 + default: + multiplier = 1.0 + } + + return multiplier * coeffs.Tags +} + +// urgencyProject calculates urgency contribution from having a project +func (t *Task) urgencyProject(coeff float64) float64 { + if t.Project != nil { + return coeff + } + return 0.0 +} + +// BuildUrgencyCoefficients creates UrgencyCoefficients from config +func BuildUrgencyCoefficients(cfg *Config) *UrgencyCoefficients { + return &UrgencyCoefficients{ + Due: cfg.UrgencyDue, + PriorityH: cfg.UrgencyPriorityH, + PriorityM: cfg.UrgencyPriorityM, + PriorityD: cfg.UrgencyPriorityD, + PriorityL: cfg.UrgencyPriorityL, + Active: cfg.UrgencyActive, + Age: cfg.UrgencyAge, + AgeMax: cfg.UrgencyAgeMax, + Tags: cfg.UrgencyTags, + Project: cfg.UrgencyProject, + Waiting: cfg.UrgencyWaiting, + UrgentTag: cfg.UrgencyUrgentTag, + UrgentCoeff: cfg.UrgencyUrgentCoeff, + } +}