From 59861bc3bf9da234802db3700dba89e04050352f Mon Sep 17 00:00:00 2001 From: Joakim Date: Mon, 5 Jan 2026 21:17:07 +0100 Subject: [PATCH] Implement report system and fix template task filtering - Fix template task filtering bug: templates now hidden from all reports except 'template' and 'all' reports, even when using custom filters - Add support for status:template filter to explicitly show templates - Implement comprehensive report system with 12 predefined reports: * active - Started tasks * all - All tasks including templates * completed - Completed tasks * list - Pending tasks (default) * minimal - Pending tasks in minimal format * newest - Most recent pending tasks * oldest - Oldest pending tasks * overdue - Overdue tasks * ready - Tasks ready to work on * recurring - Pending recurring instances * template - Recurring template tasks * waiting - Hidden/waiting tasks - Replace list command with report-based architecture - Add configurable default_report option (defaults to 'list') - Add minimal display format (ID + description only) - Support flexible syntax: 'opal [filters]' or 'opal [filters] ' - Add 'opal reports' command to list all available reports --- opal-task/cmd/list.go | 58 ----- opal-task/cmd/reports.go | 97 +++++++ opal-task/cmd/root.go | 51 +++- opal-task/internal/engine/config.go | 3 + opal-task/internal/engine/display.go | 25 ++ opal-task/internal/engine/filter.go | 32 ++- opal-task/internal/engine/report.go | 370 +++++++++++++++++++++++++++ 7 files changed, 568 insertions(+), 68 deletions(-) delete mode 100644 opal-task/cmd/list.go create mode 100644 opal-task/cmd/reports.go create mode 100644 opal-task/internal/engine/report.go diff --git a/opal-task/cmd/list.go b/opal-task/cmd/list.go deleted file mode 100644 index ebcf4ed..0000000 --- a/opal-task/cmd/list.go +++ /dev/null @@ -1,58 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "git.jnss.me/joakim/opal/internal/engine" - "github.com/spf13/cobra" -) - -var listCmd = &cobra.Command{ - Use: "list [filter...]", - Short: "List tasks", - Long: `List tasks matching the filter criteria. - -Examples: - opal list # List all pending tasks - opal list +home # List tasks with +home tag - opal list project:backend # List backend project tasks - opal list priority:H # List high priority tasks - opal 2 list # List using filter 2 (flexible syntax)`, - Run: func(cmd *cobra.Command, args []string) { - parsed := getParsedArgs(cmd) - if err := listTasks(parsed.Filters); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - }, -} - -func listTasks(args []string) error { - // Parse filter - var filter *engine.Filter - var err error - - if len(args) == 0 { - filter = engine.DefaultFilter() - } else { - filter, err = engine.ParseFilter(args) - if err != nil { - return fmt.Errorf("failed to parse filter: %w", err) - } - } - - // Build working set - ws, err := engine.BuildWorkingSet(filter) - if err != nil { - return fmt.Errorf("failed to build working set: %w", err) - } - - // Get tasks - tasks := ws.GetTasks() - - // Display - fmt.Println(engine.FormatTaskList(tasks, ws)) - - return nil -} diff --git a/opal-task/cmd/reports.go b/opal-task/cmd/reports.go new file mode 100644 index 0000000..3c4007b --- /dev/null +++ b/opal-task/cmd/reports.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "fmt" + "os" + "sort" + + "git.jnss.me/joakim/opal/internal/engine" + "github.com/spf13/cobra" +) + +// CreateReportCommands generates commands for all reports dynamically +func CreateReportCommands() []*cobra.Command { + reports := engine.AllReports() + commands := make([]*cobra.Command, 0, len(reports)) + + // Create a command for each report + for name, report := range reports { + // Capture in closure + reportName := name + reportObj := report + + cmd := &cobra.Command{ + Use: reportName + " [filter...]", + Short: reportObj.Description, + Long: fmt.Sprintf("%s\n\nThis is a report that shows: %s", reportObj.Description, reportObj.Description), + Run: func(cmd *cobra.Command, args []string) { + parsed := getParsedArgs(cmd) + if err := runReport(reportName, parsed.Filters); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, + } + + commands = append(commands, cmd) + } + + return commands +} + +// runReport executes a report by name with optional filters +func runReport(reportName string, filters []string) error { + // Get the report + report, err := engine.GetReport(reportName) + if err != nil { + return err + } + + // Execute the report + tasks, err := report.Execute(filters) + if err != nil { + return fmt.Errorf("failed to execute report: %w", err) + } + + // Build working set for display IDs + ws, err := engine.BuildWorkingSet(report.BaseFilter) + if err != nil { + return fmt.Errorf("failed to build working set: %w", err) + } + + // Display tasks based on format + var output string + if report.DisplayFormat == engine.DisplayFormatMinimal { + output = engine.FormatTaskListWithFormat(tasks, ws, "minimal") + } else { + output = engine.FormatTaskListWithFormat(tasks, ws, "table") + } + + fmt.Println(output) + return nil +} + +// reportsCmd shows all available reports +var reportsCmd = &cobra.Command{ + Use: "reports", + Short: "List all available reports", + Long: `Display a list of all available task reports with their descriptions.`, + Run: func(cmd *cobra.Command, args []string) { + reports := engine.AllReports() + + // Sort by name + names := make([]string, 0, len(reports)) + for name := range reports { + names = append(names, name) + } + sort.Strings(names) + + fmt.Println("Available reports:\n") + for _, name := range names { + report := reports[name] + fmt.Printf(" %-12s %s\n", name, report.Description) + } + fmt.Println("\nUsage: opal [filters...]") + fmt.Println("Example: opal ready +home") + }, +} diff --git a/opal-task/cmd/root.go b/opal-task/cmd/root.go index 63be7ef..7506c09 100644 --- a/opal-task/cmd/root.go +++ b/opal-task/cmd/root.go @@ -23,9 +23,16 @@ const parsedArgsKey contextKey = "parsedArgs" // Command classification var commandNames = []string{ - "add", "list", "done", "modify", "delete", + "add", "done", "modify", "delete", "start", "stop", "count", "projects", "tags", - "info", "edit", "server", "sync", + "info", "edit", "server", "sync", "reports", +} + +// Report names (dynamically populated) +var reportNames = []string{ + "active", "all", "completed", "list", "minimal", + "newest", "oldest", "overdue", "ready", "recurring", + "template", "waiting", } var commandsWithModifiers = map[string]bool{ @@ -39,9 +46,22 @@ var rootCmd = &cobra.Command{ Long: `Opal is a powerful command-line task manager inspired by taskwarrior. It supports filtering, tags, priorities, projects, and recurring tasks.`, Run: func(cmd *cobra.Command, args []string) { - // Default behavior: list tasks + // Default behavior: run configured default report (defaults to "list") parsed := getParsedArgs(cmd) - if err := listTasks(parsed.Filters); err != nil { + + // Get default report from config + cfg, err := engine.GetConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) + os.Exit(1) + } + + defaultReport := cfg.DefaultReport + if defaultReport == "" { + defaultReport = "list" + } + + if err := runReport(defaultReport, parsed.Filters); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } @@ -102,11 +122,12 @@ func preprocessArgs(args []string) *ParsedArgs { } } - // Find command position + // Find command position (check both regular commands and reports) cmdIdx := -1 cmdName := "" for i, arg := range args { + // Check regular commands for _, name := range commandNames { if arg == name { cmdIdx = i @@ -114,6 +135,16 @@ func preprocessArgs(args []string) *ParsedArgs { break } } + // Check report names + if cmdIdx < 0 { + for _, name := range reportNames { + if arg == name { + cmdIdx = i + cmdName = name + break + } + } + } if cmdIdx >= 0 { break } @@ -159,9 +190,8 @@ func preprocessArgs(args []string) *ParsedArgs { func init() { cobra.OnInitialize(initializeApp) - // Add subcommands + // Add regular subcommands rootCmd.AddCommand(addCmd) - rootCmd.AddCommand(listCmd) rootCmd.AddCommand(doneCmd) rootCmd.AddCommand(modifyCmd) rootCmd.AddCommand(deleteCmd) @@ -172,6 +202,13 @@ func init() { rootCmd.AddCommand(tagsCmd) rootCmd.AddCommand(infoCmd) rootCmd.AddCommand(editCmd) + rootCmd.AddCommand(reportsCmd) + + // Add report commands dynamically + reportCommands := CreateReportCommands() + for _, cmd := range reportCommands { + rootCmd.AddCommand(cmd) + } } func initializeApp() { diff --git a/opal-task/internal/engine/config.go b/opal-task/internal/engine/config.go index da2b32f..2ed4422 100644 --- a/opal-task/internal/engine/config.go +++ b/opal-task/internal/engine/config.go @@ -13,6 +13,7 @@ import ( type Config struct { DefaultFilter string `mapstructure:"default_filter"` DefaultSort string `mapstructure:"default_sort"` + DefaultReport string `mapstructure:"default_report"` ColorOutput bool `mapstructure:"color_output"` WeekStartDay string `mapstructure:"week_start_day"` DefaultDueTime string `mapstructure:"default_due_time"` @@ -83,6 +84,7 @@ func LoadConfig() (*Config, error) { // Set defaults v.SetDefault("default_filter", "status:pending") v.SetDefault("default_sort", "due,priority") + v.SetDefault("default_report", "list") v.SetDefault("color_output", true) v.SetDefault("week_start_day", "monday") v.SetDefault("default_due_time", "") @@ -131,6 +133,7 @@ func SaveConfig(cfg *Config) error { v.Set("default_filter", cfg.DefaultFilter) v.Set("default_sort", cfg.DefaultSort) + v.Set("default_report", cfg.DefaultReport) v.Set("color_output", cfg.ColorOutput) v.Set("week_start_day", cfg.WeekStartDay) v.Set("default_due_time", cfg.DefaultDueTime) diff --git a/opal-task/internal/engine/display.go b/opal-task/internal/engine/display.go index 84cfaee..72d3231 100644 --- a/opal-task/internal/engine/display.go +++ b/opal-task/internal/engine/display.go @@ -11,10 +11,35 @@ import ( // 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." } + // Minimal format: just ID and description + 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 + } + } + } + result += fmt.Sprintf("%3d %s\n", displayID, task.Description) + } + return result + } + + // Table format (default) t := table.NewWriter() // Configure style diff --git a/opal-task/internal/engine/filter.go b/opal-task/internal/engine/filter.go index 637a2b9..eaf62ab 100644 --- a/opal-task/internal/engine/filter.go +++ b/opal-task/internal/engine/filter.go @@ -70,11 +70,37 @@ func (f *Filter) ToSQL() (string, []interface{}) { conditions := []string{} args := []interface{}{} + // Track if we have an explicit status filter + hasStatusFilter := false + // Status filter if status, ok := f.Attributes["status"]; ok { - statusByte := statusStringToByte(status) - conditions = append(conditions, "status = ?") - args = append(args, statusByte) + hasStatusFilter = true + + // Handle special "template" status + if status == "template" { + // Templates are: status=recurring AND parent_uuid IS NULL AND recurrence_duration IS NOT NULL + conditions = append(conditions, "status = ? AND parent_uuid IS NULL AND recurrence_duration IS NOT NULL") + args = append(args, byte(StatusRecurring)) + } else { + statusByte := statusStringToByte(status) + conditions = append(conditions, "status = ?") + args = append(args, statusByte) + } + } + + // Implicit template exclusion (Option A): + // Exclude recurring templates UNLESS: + // - Filter explicitly includes status (any status, including "template") + // - This is an "all" report (marked with _all=true) + if !hasStatusFilter { + // Check if this is the "all" report + if _, isAllReport := f.Attributes["_all"]; !isAllReport { + // No explicit status filter and not "all" report - exclude templates + // Templates have status=recurring AND parent_uuid IS NULL + conditions = append(conditions, "(status != ? OR parent_uuid IS NOT NULL)") + args = append(args, byte(StatusRecurring)) + } } // Project filter diff --git a/opal-task/internal/engine/report.go b/opal-task/internal/engine/report.go new file mode 100644 index 0000000..2393aec --- /dev/null +++ b/opal-task/internal/engine/report.go @@ -0,0 +1,370 @@ +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(), + "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, + } +} + +// 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, + } +} + +// 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, + } +} + +// 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 + }, + } +} + +// 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, + } +} + +// 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, + } +} + +// 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 +}