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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user