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 <report> [filters]' or 'opal [filters] <report>'
- Add 'opal reports' command to list all available reports
This commit is contained in:
2026-01-05 21:17:07 +01:00
parent f5f7bc3ad7
commit 59861bc3bf
7 changed files with 568 additions and 68 deletions
+3
View File
@@ -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)
+25
View File
@@ -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
+29 -3
View File
@@ -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
+370
View File
@@ -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
}