Files
gems/opal-task/internal/engine/report.go
T
joakim 8f6db4672a Implement urgency system with TaskWarrior-inspired calculation
- Add urgency calculation based on multiple factors:
  * Due date (linear scale: overdue=12.0, today=10.0, week=6.0, 2weeks=2.0)
  * Priority (H=6.0, M=3.9, D=1.8, L=0.0)
  * Age (0-2.0 over 365 days)
  * Active status (+4.0 boost)
  * Waiting status (-3.0 penalty)
  * Tags (+1.0 with count modifier)
  * Project assignment (+1.0)
  * Configurable urgent tag (default 'next', +15.0)

- Replace priority column with urgency in all reports
  * Display as decimal with 1 decimal place
  * 4-tier color coding: ≥10 (bright red), ≥5 (red), ≥2 (yellow), <2 (cyan)
  * Minimal format color-coded by urgency

- Add default urgency sorting to all reports
  * list, minimal, active, ready, overdue reports sort by urgency
  * newest/oldest keep date-based sorting

- Implement 'next' report
  * Shows most urgent ready tasks
  * Configurable limit (default 5)
  * Only includes tasks ready to work on (no future wait/scheduled)

- Add urgency display to info command
  * Shows urgency score alongside priority

- All urgency coefficients configurable via config
  * Adjusted defaults for Opal's simpler model (no blocking/annotations)
  * Configurable urgent tag name (not hardcoded to 'next')

Priority order maintained: High > Medium > Default > Low
2026-01-06 14:32:44 +01:00

443 lines
11 KiB
Go

package engine
import (
"fmt"
)
// DisplayFormat defines how tasks should be displayed
type DisplayFormat string
const (
DisplayFormatTable DisplayFormat = "table"
DisplayFormatMinimal DisplayFormat = "minimal"
)
// Report defines a named task report with filters and display options
type Report struct {
Name string
Description string
BaseFilter *Filter // Base filter that cannot be overridden
DisplayFormat DisplayFormat // How to display results
SortFunc func([]*Task) []*Task
LimitFunc func([]*Task) []*Task
}
// AllReports returns all predefined reports
func AllReports() map[string]*Report {
return map[string]*Report{
"active": ActiveReport(),
"all": AllReport(),
"completed": CompletedReport(),
"list": ListReport(),
"minimal": MinimalReport(),
"newest": NewestReport(),
"next": NextReport(),
"oldest": OldestReport(),
"overdue": OverdueReport(),
"ready": ReadyReport(),
"recurring": RecurringReport(),
"template": TemplateReport(),
"waiting": WaitingReport(),
}
}
// GetReport retrieves a report by name
func GetReport(name string) (*Report, error) {
reports := AllReports()
report, exists := reports[name]
if !exists {
return nil, fmt.Errorf("unknown report: %s", name)
}
return report, nil
}
// ActiveReport shows started tasks
func ActiveReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "pending"
filter.Attributes["_started"] = "true" // Special marker for tasks with start time
return &Report{
Name: "active",
Description: "Started tasks",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
SortFunc: sortByUrgency,
}
}
// AllReport shows all tasks (no status filter)
func AllReport() *Report {
filter := NewFilter()
filter.Attributes["_all"] = "true" // Special marker to include templates
return &Report{
Name: "all",
Description: "All tasks",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
}
}
// CompletedReport shows completed tasks
func CompletedReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "completed"
return &Report{
Name: "completed",
Description: "Completed tasks",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
}
}
// ListReport shows pending tasks (default report)
func ListReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "pending"
return &Report{
Name: "list",
Description: "Pending tasks",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
SortFunc: sortByUrgency,
}
}
// MinimalReport shows pending tasks in minimal format
func MinimalReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "pending"
return &Report{
Name: "minimal",
Description: "Pending tasks (minimal format)",
BaseFilter: filter,
DisplayFormat: DisplayFormatMinimal,
SortFunc: sortByUrgency,
}
}
// NewestReport shows most recent pending tasks
func NewestReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "pending"
return &Report{
Name: "newest",
Description: "Most recent pending tasks",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
SortFunc: func(tasks []*Task) []*Task {
// Sort by created descending
sorted := make([]*Task, len(tasks))
copy(sorted, tasks)
for i := 0; i < len(sorted)-1; i++ {
for j := i + 1; j < len(sorted); j++ {
if sorted[i].Created.Before(sorted[j].Created) {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
return sorted
},
LimitFunc: func(tasks []*Task) []*Task {
if len(tasks) > 10 {
return tasks[:10]
}
return tasks
},
}
}
// 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()
filter.Attributes["status"] = "pending"
return &Report{
Name: "oldest",
Description: "Oldest pending tasks",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
SortFunc: func(tasks []*Task) []*Task {
// Sort by created ascending (already default, but explicit)
sorted := make([]*Task, len(tasks))
copy(sorted, tasks)
for i := 0; i < len(sorted)-1; i++ {
for j := i + 1; j < len(sorted); j++ {
if sorted[i].Created.After(sorted[j].Created) {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
return sorted
},
}
}
// OverdueReport shows overdue tasks
func OverdueReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "pending"
filter.Attributes["_overdue"] = "true" // Special marker for overdue tasks
return &Report{
Name: "overdue",
Description: "Overdue tasks",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
SortFunc: sortByUrgency,
}
}
// ReadyReport shows tasks ready to work on
func ReadyReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "pending"
filter.Attributes["_ready"] = "true" // Special marker for ready tasks
return &Report{
Name: "ready",
Description: "Tasks ready to work on",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
SortFunc: sortByUrgency,
}
}
// RecurringReport shows pending recurring instances
func RecurringReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "pending"
filter.Attributes["_recurring_instance"] = "true" // Special marker for recurring instances
return &Report{
Name: "recurring",
Description: "Pending recurring task instances",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
}
}
// TemplateReport shows recurring template tasks
func TemplateReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "template" // Special status for templates
return &Report{
Name: "template",
Description: "Recurring template tasks",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
}
}
// WaitingReport shows waiting/hidden tasks
func WaitingReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "pending"
filter.Attributes["_waiting"] = "true" // Special marker for waiting tasks
return &Report{
Name: "waiting",
Description: "Hidden/waiting tasks",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
}
}
// Execute runs the report with optional additional filters
func (r *Report) Execute(additionalFilters []string) ([]*Task, error) {
// Start with base filter
filter := r.BaseFilter
// Merge additional filters if provided
if len(additionalFilters) > 0 {
userFilter, err := ParseFilter(additionalFilters)
if err != nil {
return nil, fmt.Errorf("failed to parse additional filters: %w", err)
}
// Merge filters (user filters add to base, but cannot override status)
filter = mergeFilters(r.BaseFilter, userFilter)
}
// Get tasks
tasks, err := GetTasks(filter)
if err != nil {
return nil, err
}
// Apply post-filter for special markers
tasks = r.applyPostFilters(tasks)
// Apply sorting if defined
if r.SortFunc != nil {
tasks = r.SortFunc(tasks)
}
// Apply limit if defined
if r.LimitFunc != nil {
tasks = r.LimitFunc(tasks)
}
return tasks, nil
}
// applyPostFilters applies special filters that can't be done in SQL
func (r *Report) applyPostFilters(tasks []*Task) []*Task {
now := timeNow()
filtered := []*Task{}
for _, task := range tasks {
include := true
// Check for _started marker
if r.BaseFilter.Attributes["_started"] == "true" {
if task.Start == nil {
include = false
}
}
// Check for _overdue marker
if r.BaseFilter.Attributes["_overdue"] == "true" {
if task.Due == nil || !task.Due.Before(now) {
include = false
}
}
// Check for _ready marker
if r.BaseFilter.Attributes["_ready"] == "true" {
// Task is ready if scheduled and wait are either null or in the past
if task.Scheduled != nil && task.Scheduled.After(now) {
include = false
}
if task.Wait != nil && task.Wait.After(now) {
include = false
}
}
// Check for _waiting marker
if r.BaseFilter.Attributes["_waiting"] == "true" {
if task.Wait == nil || !task.Wait.After(now) {
include = false
}
}
// Check for _recurring_instance marker
if r.BaseFilter.Attributes["_recurring_instance"] == "true" {
if task.ParentUUID == nil {
include = false
}
}
if include {
filtered = append(filtered, task)
}
}
return filtered
}
// mergeFilters combines base filter with user filter
func mergeFilters(base, user *Filter) *Filter {
merged := NewFilter()
// Copy base attributes (these take precedence)
for k, v := range base.Attributes {
merged.Attributes[k] = v
}
// Add user attributes (but don't override status or special markers)
for k, v := range user.Attributes {
if k != "status" && k[0] != '_' {
merged.Attributes[k] = v
}
}
// Merge tags
merged.IncludeTags = append(merged.IncludeTags, base.IncludeTags...)
merged.IncludeTags = append(merged.IncludeTags, user.IncludeTags...)
merged.ExcludeTags = append(merged.ExcludeTags, base.ExcludeTags...)
merged.ExcludeTags = append(merged.ExcludeTags, user.ExcludeTags...)
// Merge IDs and UUIDs
merged.IDs = append(merged.IDs, base.IDs...)
merged.IDs = append(merged.IDs, user.IDs...)
merged.UUIDs = append(merged.UUIDs, base.UUIDs...)
merged.UUIDs = append(merged.UUIDs, user.UUIDs...)
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
}