diff --git a/opal-task/internal/engine/dateparse.go b/opal-task/internal/engine/dateparse.go new file mode 100644 index 0000000..ed5a542 --- /dev/null +++ b/opal-task/internal/engine/dateparse.go @@ -0,0 +1,63 @@ +package engine + +import ( + "fmt" + "strings" + "time" +) + +// ParseDate parses date strings with smart interpretation +// Supports: ISO dates, relative (tomorrow, today), weekdays (sun, monday) +func ParseDate(s string) (time.Time, error) { + s = strings.ToLower(strings.TrimSpace(s)) + now := timeNow() + + // Try ISO format first + if t, err := time.Parse("2006-01-02", s); err == nil { + return t, nil + } + + // Relative dates + switch s { + case "today": + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()), nil + case "tomorrow": + tomorrow := now.AddDate(0, 0, 1) + return time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, tomorrow.Location()), nil + case "yesterday": + yesterday := now.AddDate(0, 0, -1) + return time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location()), nil + } + + // Weekday names + weekdays := map[string]time.Weekday{ + "sun": time.Sunday, "sunday": time.Sunday, + "mon": time.Monday, "monday": time.Monday, + "tue": time.Tuesday, "tuesday": time.Tuesday, + "wed": time.Wednesday, "wednesday": time.Wednesday, + "thu": time.Thursday, "thursday": time.Thursday, + "fri": time.Friday, "friday": time.Friday, + "sat": time.Saturday, "saturday": time.Saturday, + } + + if targetWeekday, ok := weekdays[s]; ok { + return nextWeekday(now, targetWeekday), nil + } + + return time.Time{}, fmt.Errorf("unable to parse date: %s", s) +} + +// nextWeekday returns the next occurrence of the target weekday +// Smart logic: if today is Thursday and target is Sunday, returns this Sunday +// If today is Sunday and target is Sunday, returns next Sunday +func nextWeekday(from time.Time, target time.Weekday) time.Time { + // Calculate days until target + daysUntil := int(target - from.Weekday()) + + if daysUntil <= 0 { + daysUntil += 7 // Next week + } + + next := from.AddDate(0, 0, daysUntil) + return time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, next.Location()) +} diff --git a/opal-task/internal/engine/filter.go b/opal-task/internal/engine/filter.go new file mode 100644 index 0000000..637a2b9 --- /dev/null +++ b/opal-task/internal/engine/filter.go @@ -0,0 +1,189 @@ +package engine + +import ( + "fmt" + "strconv" + "strings" +) + +type Filter struct { + IncludeTags []string // +tag + ExcludeTags []string // -tag + Attributes map[string]string // status:pending, project:work + IDs []int // numeric display IDs + UUIDs []string // uuid:abc123 +} + +func NewFilter() *Filter { + return &Filter{ + IncludeTags: []string{}, + ExcludeTags: []string{}, + Attributes: make(map[string]string), + IDs: []int{}, + UUIDs: []string{}, + } +} + +// DefaultFilter returns filter for default view (pending + started tasks) +func DefaultFilter() *Filter { + f := NewFilter() + f.Attributes["status"] = "pending" + return f +} + +// ParseFilter parses command-line args into Filter +func ParseFilter(args []string) (*Filter, error) { + f := NewFilter() + + for _, arg := range args { + if strings.HasPrefix(arg, "+") { + // Include tag + f.IncludeTags = append(f.IncludeTags, strings.TrimPrefix(arg, "+")) + } else if strings.HasPrefix(arg, "-") && !strings.Contains(arg, ":") { + // Exclude tag (but not negative modifiers like priority:-) + f.ExcludeTags = append(f.ExcludeTags, strings.TrimPrefix(arg, "-")) + } else if strings.Contains(arg, ":") { + // Attribute filter + parts := strings.SplitN(arg, ":", 2) + key := parts[0] + value := parts[1] + + if key == "uuid" { + f.UUIDs = append(f.UUIDs, value) + } else { + f.Attributes[key] = value + } + } else { + // Try parsing as numeric ID + id, err := strconv.Atoi(arg) + if err == nil { + f.IDs = append(f.IDs, id) + } + } + } + + return f, nil +} + +// ToSQL generates SQL WHERE clause and args +func (f *Filter) ToSQL() (string, []interface{}) { + conditions := []string{} + args := []interface{}{} + + // Status filter + if status, ok := f.Attributes["status"]; ok { + statusByte := statusStringToByte(status) + conditions = append(conditions, "status = ?") + args = append(args, statusByte) + } + + // Project filter + if project, ok := f.Attributes["project"]; ok { + conditions = append(conditions, "project = ?") + args = append(args, project) + } + + // Priority filter + if priority, ok := f.Attributes["priority"]; ok { + priorityInt := priorityStringToInt(priority) + conditions = append(conditions, "priority = ?") + args = append(args, priorityInt) + } + + // Tag filters (requires subquery) + for _, tag := range f.IncludeTags { + conditions = append(conditions, ` + EXISTS ( + SELECT 1 FROM tags + WHERE tags.task_id = tasks.id AND tags.tag = ? + ) + `) + args = append(args, tag) + } + + for _, tag := range f.ExcludeTags { + conditions = append(conditions, ` + NOT EXISTS ( + SELECT 1 FROM tags + WHERE tags.task_id = tasks.id AND tags.tag = ? + ) + `) + args = append(args, tag) + } + + // UUID filter + if len(f.UUIDs) > 0 { + placeholders := strings.Repeat("?,", len(f.UUIDs)) + placeholders = placeholders[:len(placeholders)-1] + conditions = append(conditions, fmt.Sprintf("uuid IN (%s)", placeholders)) + for _, u := range f.UUIDs { + args = append(args, u) + } + } + + // ID filter (resolve via working_set table) + if len(f.IDs) > 0 { + placeholders := strings.Repeat("?,", len(f.IDs)) + placeholders = placeholders[:len(placeholders)-1] + conditions = append(conditions, fmt.Sprintf(` + uuid IN ( + SELECT task_uuid FROM working_set + WHERE display_id IN (%s) + ) + `, placeholders)) + for _, id := range f.IDs { + args = append(args, id) + } + } + + if len(conditions) == 0 { + return "1=1", args + } + + return strings.Join(conditions, " AND "), args +} + +func statusStringToByte(s string) byte { + switch strings.ToLower(s) { + case "pending": + return byte(StatusPending) + case "completed": + return byte(StatusCompleted) + case "deleted": + return byte(StatusDeleted) + case "recurring": + return byte(StatusRecurring) + default: + return byte(StatusPending) + } +} + +func priorityStringToInt(s string) int { + switch strings.ToUpper(s) { + case "L", "LOW": + return int(PriorityLow) + case "M", "MEDIUM": + return int(PriorityMedium) + case "H", "HIGH": + return int(PriorityHigh) + case "D", "DEFAULT": + return int(PriorityDefault) + default: + return int(PriorityDefault) + } +} + +func priorityIntToString(p Priority) string { + switch p { + case PriorityLow: + return "L" + case PriorityMedium: + return "M" + case PriorityHigh: + return "H" + case PriorityDefault: + return "D" + default: + return "D" + } +} diff --git a/opal-task/internal/engine/filter_test.go b/opal-task/internal/engine/filter_test.go new file mode 100644 index 0000000..b31958a --- /dev/null +++ b/opal-task/internal/engine/filter_test.go @@ -0,0 +1,233 @@ +package engine + +import ( + "testing" +) + +func TestParseFilter(t *testing.T) { + tests := []struct { + name string + args []string + expected *Filter + }{ + { + name: "parse include tags", + args: []string{"+home", "+urgent"}, + expected: &Filter{ + IncludeTags: []string{"home", "urgent"}, + ExcludeTags: []string{}, + Attributes: map[string]string{}, + IDs: []int{}, + UUIDs: []string{}, + }, + }, + { + name: "parse exclude tags", + args: []string{"-garden", "-someday"}, + expected: &Filter{ + IncludeTags: []string{}, + ExcludeTags: []string{"garden", "someday"}, + Attributes: map[string]string{}, + IDs: []int{}, + UUIDs: []string{}, + }, + }, + { + name: "parse attributes", + args: []string{"status:pending", "project:backend", "priority:H"}, + expected: &Filter{ + IncludeTags: []string{}, + ExcludeTags: []string{}, + Attributes: map[string]string{ + "status": "pending", + "project": "backend", + "priority": "H", + }, + IDs: []int{}, + UUIDs: []string{}, + }, + }, + { + name: "parse numeric IDs", + args: []string{"1", "5", "10"}, + expected: &Filter{ + IncludeTags: []string{}, + ExcludeTags: []string{}, + Attributes: map[string]string{}, + IDs: []int{1, 5, 10}, + UUIDs: []string{}, + }, + }, + { + name: "parse UUID", + args: []string{"uuid:abc-123"}, + expected: &Filter{ + IncludeTags: []string{}, + ExcludeTags: []string{}, + Attributes: map[string]string{}, + IDs: []int{}, + UUIDs: []string{"abc-123"}, + }, + }, + { + name: "compound filter", + args: []string{"+home", "-garden", "status:pending", "priority:H"}, + expected: &Filter{ + IncludeTags: []string{"home"}, + ExcludeTags: []string{"garden"}, + Attributes: map[string]string{ + "status": "pending", + "priority": "H", + }, + IDs: []int{}, + UUIDs: []string{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseFilter(tt.args) + if err != nil { + t.Fatalf("ParseFilter returned error: %v", err) + } + + // Check include tags + if len(result.IncludeTags) != len(tt.expected.IncludeTags) { + t.Errorf("Expected %d include tags, got %d", len(tt.expected.IncludeTags), len(result.IncludeTags)) + } + for i, tag := range tt.expected.IncludeTags { + if i >= len(result.IncludeTags) || result.IncludeTags[i] != tag { + t.Errorf("Include tag mismatch at index %d: expected %s, got %v", i, tag, result.IncludeTags) + } + } + + // Check exclude tags + if len(result.ExcludeTags) != len(tt.expected.ExcludeTags) { + t.Errorf("Expected %d exclude tags, got %d", len(tt.expected.ExcludeTags), len(result.ExcludeTags)) + } + + // Check attributes + if len(result.Attributes) != len(tt.expected.Attributes) { + t.Errorf("Expected %d attributes, got %d", len(tt.expected.Attributes), len(result.Attributes)) + } + for key, val := range tt.expected.Attributes { + if result.Attributes[key] != val { + t.Errorf("Attribute %s: expected %s, got %s", key, val, result.Attributes[key]) + } + } + + // Check IDs + if len(result.IDs) != len(tt.expected.IDs) { + t.Errorf("Expected %d IDs, got %d", len(tt.expected.IDs), len(result.IDs)) + } + }) + } +} + +func TestDefaultFilter(t *testing.T) { + filter := DefaultFilter() + + if filter.Attributes["status"] != "pending" { + t.Error("DefaultFilter should have status:pending") + } +} + +func TestFilterToSQL(t *testing.T) { + // Test status filter + filter := &Filter{ + Attributes: map[string]string{ + "status": "pending", + }, + } + + where, args := filter.ToSQL() + if where != "status = ?" { + t.Errorf("Expected 'status = ?', got '%s'", where) + } + if len(args) != 1 || args[0] != byte(StatusPending) { + t.Errorf("Expected args [%d], got %v", byte(StatusPending), args) + } + + // Test tag filter + filter = &Filter{ + IncludeTags: []string{"urgent"}, + } + + where, args = filter.ToSQL() + if len(args) != 1 || args[0] != "urgent" { + t.Errorf("Expected tag 'urgent' in args, got %v", args) + } +} + +func TestGetTasksWithFilter(t *testing.T) { + // Create tasks with different attributes + task1, _ := CreateTask("Backend task") + project := "backend" + task1.Project = &project + task1.Priority = PriorityHigh + task1.Save() + task1.AddTag("urgent") + + task2, _ := CreateTask("Frontend task") + project2 := "frontend" + task2.Project = &project2 + task2.Priority = PriorityMedium + task2.Save() + task2.AddTag("bug") + + // Filter by project + filter, _ := ParseFilter([]string{"project:backend"}) + tasks, err := GetTasks(filter) + if err != nil { + t.Fatalf("GetTasks failed: %v", err) + } + + // Should only get backend task + found := false + for _, task := range tasks { + if task.Project != nil && *task.Project == "backend" { + found = true + } + } + if !found { + t.Error("Expected to find backend task") + } + + // Filter by tag + filter2, _ := ParseFilter([]string{"+urgent"}) + tasks2, err := GetTasks(filter2) + if err != nil { + t.Fatalf("GetTasks with tag filter failed: %v", err) + } + + found = false + for _, task := range tasks2 { + for _, tag := range task.Tags { + if tag == "urgent" { + found = true + break + } + } + } + if !found { + t.Error("Expected to find task with urgent tag") + } + + // Filter by priority + filter3, _ := ParseFilter([]string{"priority:H"}) + tasks3, err := GetTasks(filter3) + if err != nil { + t.Fatalf("GetTasks with priority filter failed: %v", err) + } + + found = false + for _, task := range tasks3 { + if task.Priority == PriorityHigh { + found = true + } + } + if !found { + t.Error("Expected to find high priority task") + } +} diff --git a/opal-task/internal/engine/modifier.go b/opal-task/internal/engine/modifier.go new file mode 100644 index 0000000..edad930 --- /dev/null +++ b/opal-task/internal/engine/modifier.go @@ -0,0 +1,204 @@ +package engine + +import ( + "fmt" + "strings" +) + +type Modifier struct { + SetAttributes map[string]*string // key -> value (nil = clear) + AddTags []string + RemoveTags []string +} + +func NewModifier() *Modifier { + return &Modifier{ + SetAttributes: make(map[string]*string), + AddTags: []string{}, + RemoveTags: []string{}, + } +} + +// ParseModifier parses command-line args into Modifier +func ParseModifier(args []string) (*Modifier, error) { + m := NewModifier() + + for _, arg := range args { + if strings.HasPrefix(arg, "+") { + // Add tag + m.AddTags = append(m.AddTags, strings.TrimPrefix(arg, "+")) + } else if strings.HasPrefix(arg, "-") && !strings.Contains(arg, ":") { + // Remove tag + m.RemoveTags = append(m.RemoveTags, strings.TrimPrefix(arg, "-")) + } else if strings.Contains(arg, ":") { + // Attribute modification + parts := strings.SplitN(arg, ":", 2) + key := parts[0] + value := parts[1] + + if value == "" { + // Clear attribute (priority: with no value) + m.SetAttributes[key] = nil + } else { + m.SetAttributes[key] = &value + } + } + } + + return m, nil +} + +// Apply applies modifier to task +func (m *Modifier) Apply(task *Task) error { + // Apply attribute changes + for key, valuePtr := range m.SetAttributes { + switch key { + case "priority": + if valuePtr == nil { + task.Priority = PriorityDefault + } else { + task.Priority = Priority(priorityStringToInt(*valuePtr)) + } + case "project": + task.Project = valuePtr + case "due": + if valuePtr == nil { + task.Due = nil + } else { + parsed, err := ParseDate(*valuePtr) + if err != nil { + return fmt.Errorf("invalid due date: %w", err) + } + task.Due = &parsed + } + case "scheduled": + if valuePtr == nil { + task.Scheduled = nil + } else { + parsed, err := ParseDate(*valuePtr) + if err != nil { + return fmt.Errorf("invalid scheduled date: %w", err) + } + task.Scheduled = &parsed + } + case "wait": + if valuePtr == nil { + task.Wait = nil + } else { + parsed, err := ParseDate(*valuePtr) + if err != nil { + return fmt.Errorf("invalid wait date: %w", err) + } + task.Wait = &parsed + } + case "until": + if valuePtr == nil { + task.Until = nil + } else { + parsed, err := ParseDate(*valuePtr) + if err != nil { + return fmt.Errorf("invalid until date: %w", err) + } + task.Until = &parsed + } + case "recur": + if valuePtr == nil { + task.RecurrenceDuration = nil + } else { + duration, err := ParseRecurrencePattern(*valuePtr) + if err != nil { + return fmt.Errorf("invalid recurrence: %w", err) + } + task.RecurrenceDuration = &duration + } + } + } + + // Apply tag changes + for _, tag := range m.AddTags { + if err := task.AddTag(tag); err != nil { + return err + } + } + + for _, tag := range m.RemoveTags { + if err := task.RemoveTag(tag); err != nil { + return err + } + } + + task.Modified = timeNow() + + return task.Save() +} + +// ApplyToNew applies modifier to a new task (before it's saved) +// This is used when creating tasks with modifiers +func (m *Modifier) ApplyToNew(task *Task) error { + // Apply attribute changes (same as Apply but without Save) + for key, valuePtr := range m.SetAttributes { + switch key { + case "priority": + if valuePtr == nil { + task.Priority = PriorityDefault + } else { + task.Priority = Priority(priorityStringToInt(*valuePtr)) + } + case "project": + task.Project = valuePtr + case "due": + if valuePtr == nil { + task.Due = nil + } else { + parsed, err := ParseDate(*valuePtr) + if err != nil { + return fmt.Errorf("invalid due date: %w", err) + } + task.Due = &parsed + } + case "scheduled": + if valuePtr == nil { + task.Scheduled = nil + } else { + parsed, err := ParseDate(*valuePtr) + if err != nil { + return fmt.Errorf("invalid scheduled date: %w", err) + } + task.Scheduled = &parsed + } + case "wait": + if valuePtr == nil { + task.Wait = nil + } else { + parsed, err := ParseDate(*valuePtr) + if err != nil { + return fmt.Errorf("invalid wait date: %w", err) + } + task.Wait = &parsed + } + case "until": + if valuePtr == nil { + task.Until = nil + } else { + parsed, err := ParseDate(*valuePtr) + if err != nil { + return fmt.Errorf("invalid until date: %w", err) + } + task.Until = &parsed + } + case "recur": + if valuePtr == nil { + task.RecurrenceDuration = nil + } else { + duration, err := ParseRecurrencePattern(*valuePtr) + if err != nil { + return fmt.Errorf("invalid recurrence: %w", err) + } + task.RecurrenceDuration = &duration + } + } + } + + // Note: Tags are added after task is saved (in CreateTask function) + return nil +} diff --git a/opal-task/internal/engine/modifier_test.go b/opal-task/internal/engine/modifier_test.go new file mode 100644 index 0000000..19e12b8 --- /dev/null +++ b/opal-task/internal/engine/modifier_test.go @@ -0,0 +1,271 @@ +package engine + +import ( + "testing" + "time" +) + +func TestParseModifier(t *testing.T) { + tests := []struct { + name string + args []string + checkFn func(*testing.T, *Modifier) + }{ + { + name: "add tags", + args: []string{"+urgent", "+work"}, + checkFn: func(t *testing.T, m *Modifier) { + if len(m.AddTags) != 2 { + t.Errorf("Expected 2 add tags, got %d", len(m.AddTags)) + } + if m.AddTags[0] != "urgent" || m.AddTags[1] != "work" { + t.Error("Tags not parsed correctly") + } + }, + }, + { + name: "remove tags", + args: []string{"-someday", "-later"}, + checkFn: func(t *testing.T, m *Modifier) { + if len(m.RemoveTags) != 2 { + t.Errorf("Expected 2 remove tags, got %d", len(m.RemoveTags)) + } + }, + }, + { + name: "set attributes", + args: []string{"priority:H", "project:backend", "due:tomorrow"}, + checkFn: func(t *testing.T, m *Modifier) { + if len(m.SetAttributes) != 3 { + t.Errorf("Expected 3 attributes, got %d", len(m.SetAttributes)) + } + if m.SetAttributes["priority"] == nil || *m.SetAttributes["priority"] != "H" { + t.Error("Priority not set correctly") + } + if m.SetAttributes["project"] == nil || *m.SetAttributes["project"] != "backend" { + t.Error("Project not set correctly") + } + }, + }, + { + name: "clear attribute", + args: []string{"priority:"}, + checkFn: func(t *testing.T, m *Modifier) { + if m.SetAttributes["priority"] != nil { + t.Error("Expected priority to be nil (cleared)") + } + }, + }, + { + name: "compound modifier", + args: []string{"+urgent", "-someday", "priority:H", "due:tomorrow"}, + checkFn: func(t *testing.T, m *Modifier) { + if len(m.AddTags) != 1 || m.AddTags[0] != "urgent" { + t.Error("Add tag not parsed") + } + if len(m.RemoveTags) != 1 || m.RemoveTags[0] != "someday" { + t.Error("Remove tag not parsed") + } + if len(m.SetAttributes) != 2 { + t.Errorf("Expected 2 attributes, got %d", len(m.SetAttributes)) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseModifier(tt.args) + if err != nil { + t.Fatalf("ParseModifier returned error: %v", err) + } + tt.checkFn(t, result) + }) + } +} + +func TestModifierApply(t *testing.T) { + // Create a task + task, err := CreateTask("Test task") + if err != nil { + t.Fatalf("Failed to create task: %v", err) + } + + // Create modifier + mod, _ := ParseModifier([]string{"priority:H", "project:backend", "+urgent"}) + + // Apply modifier + if err := mod.Apply(task); err != nil { + t.Fatalf("Failed to apply modifier: %v", err) + } + + // Verify changes + if task.Priority != PriorityHigh { + t.Error("Priority not updated") + } + + if task.Project == nil || *task.Project != "backend" { + t.Error("Project not updated") + } + + // Reload to verify tags were saved + reloaded, _ := GetTask(task.UUID) + found := false + for _, tag := range reloaded.Tags { + if tag == "urgent" { + found = true + } + } + if !found { + t.Error("Tag not added") + } +} + +func TestCreateTaskWithModifier(t *testing.T) { + mod, _ := ParseModifier([]string{"priority:H", "project:test", "+work", "+urgent"}) + + task, err := CreateTaskWithModifier("Task with modifiers", mod) + if err != nil { + t.Fatalf("Failed to create task with modifier: %v", err) + } + + if task.Priority != PriorityHigh { + t.Error("Priority not set during creation") + } + + if task.Project == nil || *task.Project != "test" { + t.Error("Project not set during creation") + } + + if len(task.Tags) != 2 { + t.Errorf("Expected 2 tags, got %d", len(task.Tags)) + } +} + +func TestParseDateISO(t *testing.T) { + date, err := ParseDate("2026-01-15") + if err != nil { + t.Fatalf("Failed to parse ISO date: %v", err) + } + + if date.Year() != 2026 || date.Month() != 1 || date.Day() != 15 { + t.Errorf("Date not parsed correctly: %v", date) + } +} + +func TestParseDateRelative(t *testing.T) { + now := time.Now() + + // Test today + today, err := ParseDate("today") + if err != nil { + t.Fatalf("Failed to parse 'today': %v", err) + } + if today.Day() != now.Day() { + t.Error("'today' not parsed correctly") + } + + // Test tomorrow + tomorrow, err := ParseDate("tomorrow") + if err != nil { + t.Fatalf("Failed to parse 'tomorrow': %v", err) + } + expected := now.AddDate(0, 0, 1) + if tomorrow.Day() != expected.Day() { + t.Error("'tomorrow' not parsed correctly") + } +} + +func TestParseDateWeekday(t *testing.T) { + // Test Sunday + sunday, err := ParseDate("sunday") + if err != nil { + t.Fatalf("Failed to parse 'sunday': %v", err) + } + if sunday.Weekday() != time.Sunday { + t.Error("'sunday' not parsed correctly") + } + + // Test abbreviated form + mon, err := ParseDate("mon") + if err != nil { + t.Fatalf("Failed to parse 'mon': %v", err) + } + if mon.Weekday() != time.Monday { + t.Error("'mon' not parsed correctly") + } +} + +func TestNextWeekday(t *testing.T) { + // Test case: Thursday -> next Sunday should be this Sunday + thursday := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) // Jan 1, 2026 is a Thursday + nextSun := nextWeekday(thursday, time.Sunday) + + if nextSun.Weekday() != time.Sunday { + t.Error("Should return Sunday") + } + + // Should be 3 days later (this Sunday) + expectedDays := 3 + actualDays := int(nextSun.Sub(thursday).Hours() / 24) + if actualDays != expectedDays { + t.Errorf("Expected %d days until Sunday, got %d", expectedDays, actualDays) + } + + // Test case: Sunday -> next Sunday should be 7 days later + sunday := time.Date(2026, 1, 4, 0, 0, 0, 0, time.UTC) // Jan 4, 2026 is a Sunday + nextSun2 := nextWeekday(sunday, time.Sunday) + + expectedDays = 7 + actualDays = int(nextSun2.Sub(sunday).Hours() / 24) + if actualDays != expectedDays { + t.Errorf("Sunday to Sunday: expected %d days, got %d", expectedDays, actualDays) + } +} + +func TestModifierWithDates(t *testing.T) { + task, _ := CreateTask("Test date modifiers") + + // Apply due date + mod, _ := ParseModifier([]string{"due:tomorrow"}) + if err := mod.Apply(task); err != nil { + t.Fatalf("Failed to apply date modifier: %v", err) + } + + if task.Due == nil { + t.Error("Due date should be set") + } + + tomorrow := time.Now().AddDate(0, 0, 1) + if task.Due.Day() != tomorrow.Day() { + t.Error("Due date not set to tomorrow") + } + + // Clear due date + mod2, _ := ParseModifier([]string{"due:"}) + if err := mod2.Apply(task); err != nil { + t.Fatalf("Failed to clear due date: %v", err) + } + + if task.Due != nil { + t.Error("Due date should be cleared") + } +} + +func TestModifierWithRecurrence(t *testing.T) { + task, _ := CreateTask("Test recurrence modifier") + + mod, _ := ParseModifier([]string{"recur:1w"}) + if err := mod.Apply(task); err != nil { + t.Fatalf("Failed to apply recurrence: %v", err) + } + + if task.RecurrenceDuration == nil { + t.Error("Recurrence should be set") + } + + expected := 7 * 24 * time.Hour + if *task.RecurrenceDuration != expected { + t.Errorf("Expected recurrence %v, got %v", expected, *task.RecurrenceDuration) + } +} diff --git a/opal-task/internal/engine/task.go b/opal-task/internal/engine/task.go index aacd002..ec88ba7 100644 --- a/opal-task/internal/engine/task.go +++ b/opal-task/internal/engine/task.go @@ -139,6 +139,11 @@ func sqlToUUIDPtr(v interface{}) *uuid.UUID { // CreateTask creates a new task with the given description func CreateTask(description string) (*Task, error) { + return CreateTaskWithModifier(description, nil) +} + +// CreateTaskWithModifier creates a new task with the given description and applies modifiers +func CreateTaskWithModifier(description string, mod *Modifier) (*Task, error) { now := timeNow() task := &Task{ UUID: uuid.New(), @@ -150,10 +155,26 @@ func CreateTask(description string) (*Task, error) { Tags: []string{}, } + // Apply modifiers before saving (for attributes) + if mod != nil { + if err := mod.ApplyToNew(task); err != nil { + return nil, err + } + } + if err := task.Save(); err != nil { return nil, err } + // Apply tags after saving (requires task.ID) + if mod != nil { + for _, tag := range mod.AddTags { + if err := task.AddTag(tag); err != nil { + return nil, err + } + } + } + return task, nil } @@ -242,22 +263,33 @@ func GetTask(taskUUID uuid.UUID) (*Task, error) { return task, nil } -// GetTasks retrieves all tasks (filtering will be added later) -func GetTasks() ([]*Task, error) { +// GetTasks retrieves all tasks with optional filtering +func GetTasks(filter *Filter) ([]*Task, error) { db := GetDB() if db == nil { return nil, fmt.Errorf("database not initialized") } - query := ` + // Build WHERE clause from filter + whereClause := "1=1" + var args []interface{} + if filter != nil { + whereClause, args = filter.ToSQL() + } + + query := fmt.Sprintf(` SELECT id, uuid, status, description, project, priority, created, modified, start, end, due, scheduled, wait, until_date, recurrence_duration, parent_uuid FROM tasks - ORDER BY due ASC, priority DESC - ` + WHERE %s + ORDER BY + CASE WHEN due IS NULL THEN 1 ELSE 0 END, + due ASC, + priority DESC + `, whereClause) - rows, err := db.Query(query) + rows, err := db.Query(query, args...) if err != nil { return nil, fmt.Errorf("failed to query tasks: %w", err) } diff --git a/opal-task/internal/engine/task_test.go b/opal-task/internal/engine/task_test.go index bcdf828..8057c54 100644 --- a/opal-task/internal/engine/task_test.go +++ b/opal-task/internal/engine/task_test.go @@ -252,7 +252,7 @@ func TestGetTasks(t *testing.T) { CreateTask("Task 2") CreateTask("Task 3") - tasks, err := GetTasks() + tasks, err := GetTasks(nil) if err != nil { t.Fatalf("Failed to get tasks: %v", err) }