diff --git a/opal-task/internal/engine/recurrence.go b/opal-task/internal/engine/recurrence.go index 3cfaff5..22179bb 100644 --- a/opal-task/internal/engine/recurrence.go +++ b/opal-task/internal/engine/recurrence.go @@ -4,6 +4,8 @@ import ( "fmt" "strconv" "time" + + "github.com/google/uuid" ) // ParseRecurrencePattern converts "1w", "2d", "1m" to time.Duration @@ -58,12 +60,67 @@ func CalculateNextDue(currentDue time.Time, recurrence time.Duration) time.Time } // SpawnNextInstance creates a new task instance from completed recurring task -// This will be implemented after we have the CRUD operations func SpawnNextInstance(completedInstance *Task) error { if completedInstance.ParentUUID == nil { return fmt.Errorf("task is not a recurring instance") } - // TODO: Implement after GetTask is available - return fmt.Errorf("not implemented yet") + // Load template + template, err := GetTask(*completedInstance.ParentUUID) + if err != nil { + return fmt.Errorf("failed to load template: %w", err) + } + + if template.RecurrenceDuration == nil { + return fmt.Errorf("template has no recurrence duration") + } + + // Calculate next due date + var nextDue *time.Time + if completedInstance.Due != nil { + next := CalculateNextDue(*completedInstance.Due, *template.RecurrenceDuration) + nextDue = &next + } + + // Check if we're past 'until' date + if template.Until != nil && nextDue != nil && nextDue.After(*template.Until) { + // Don't spawn, recurrence has expired + return nil + } + + // Create new instance + now := time.Now() + newInstance := &Task{ + UUID: uuid.New(), + Status: StatusPending, + Description: template.Description, + Project: template.Project, + Priority: template.Priority, + Created: now, + Modified: now, + Due: nextDue, + Scheduled: template.Scheduled, + Wait: template.Wait, + Until: template.Until, + ParentUUID: &template.UUID, + Tags: []string{}, + } + + if err := newInstance.Save(); err != nil { + return fmt.Errorf("failed to save new instance: %w", err) + } + + // Copy tags from template + templateTags, err := template.GetTags() + if err != nil { + return fmt.Errorf("failed to get template tags: %w", err) + } + + for _, tag := range templateTags { + if err := newInstance.AddTag(tag); err != nil { + return fmt.Errorf("failed to add tag: %w", err) + } + } + + return nil } diff --git a/opal-task/internal/engine/recurrence_test.go b/opal-task/internal/engine/recurrence_test.go new file mode 100644 index 0000000..16c1c60 --- /dev/null +++ b/opal-task/internal/engine/recurrence_test.go @@ -0,0 +1,317 @@ +package engine + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +func TestParseRecurrencePattern(t *testing.T) { + tests := []struct { + pattern string + expected time.Duration + }{ + {"1d", 24 * time.Hour}, + {"7d", 7 * 24 * time.Hour}, + {"1w", 7 * 24 * time.Hour}, + {"2w", 14 * 24 * time.Hour}, + {"1m", 30 * 24 * time.Hour}, + {"1y", 365 * 24 * time.Hour}, + } + + for _, tt := range tests { + t.Run(tt.pattern, func(t *testing.T) { + result, err := ParseRecurrencePattern(tt.pattern) + if err != nil { + t.Fatalf("Failed to parse pattern %s: %v", tt.pattern, err) + } + if result != tt.expected { + t.Errorf("Pattern %s: expected %v, got %v", tt.pattern, tt.expected, result) + } + }) + } +} + +func TestFormatRecurrenceDuration(t *testing.T) { + tests := []struct { + duration time.Duration + expected string + }{ + {24 * time.Hour, "1d"}, + {7 * 24 * time.Hour, "1w"}, + {14 * 24 * time.Hour, "2w"}, + {30 * 24 * time.Hour, "1m"}, + {365 * 24 * time.Hour, "1y"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := FormatRecurrenceDuration(tt.duration) + if result != tt.expected { + t.Errorf("Duration %v: expected %s, got %s", tt.duration, tt.expected, result) + } + }) + } +} + +func TestCalculateNextDue(t *testing.T) { + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + // Test weekly recurrence + next := CalculateNextDue(base, 7*24*time.Hour) + expected := time.Date(2026, 1, 8, 0, 0, 0, 0, time.UTC) + + if !next.Equal(expected) { + t.Errorf("Expected %v, got %v", expected, next) + } + + // Test daily recurrence + next2 := CalculateNextDue(base, 24*time.Hour) + expected2 := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC) + + if !next2.Equal(expected2) { + t.Errorf("Expected %v, got %v", expected2, next2) + } +} + +func TestRecurringTaskCreation(t *testing.T) { + // Create a recurring task (template + first instance) + dueDate := time.Now().Add(24 * time.Hour) + duration := 7 * 24 * time.Hour + + // Create template + template := &Task{ + UUID: uuid.New(), + Status: StatusRecurring, + Description: "Weekly review", + Priority: PriorityMedium, + Created: time.Now(), + Modified: time.Now(), + Due: &dueDate, + RecurrenceDuration: &duration, + ParentUUID: nil, // This is the template + Tags: []string{}, + } + + if err := template.Save(); err != nil { + t.Fatalf("Failed to save template: %v", err) + } + + if err := template.AddTag("recurring"); err != nil { + t.Fatalf("Failed to add tag to template: %v", err) + } + + // Create first instance + instance := &Task{ + UUID: uuid.New(), + Status: StatusPending, + Description: template.Description, + Priority: template.Priority, + Created: time.Now(), + Modified: time.Now(), + Due: template.Due, + ParentUUID: &template.UUID, + Tags: []string{}, + } + + if err := instance.Save(); err != nil { + t.Fatalf("Failed to save instance: %v", err) + } + + if err := instance.AddTag("recurring"); err != nil { + t.Fatalf("Failed to add tag to instance: %v", err) + } + + // Verify instance is linked to template + if instance.ParentUUID == nil { + t.Error("Instance should have parent UUID") + } + + if *instance.ParentUUID != template.UUID { + t.Error("Instance parent UUID should match template UUID") + } + + // Verify template is recurring + if !template.IsRecurringTemplate() { + t.Error("Template should be identified as recurring template") + } + + // Verify instance is a recurring instance + if !instance.IsRecurringInstance() { + t.Error("Instance should be identified as recurring instance") + } +} + +func TestSpawnNextInstance(t *testing.T) { + // Create template + dueDate := time.Now().Add(24 * time.Hour) + duration := 7 * 24 * time.Hour + + template := &Task{ + UUID: uuid.New(), + Status: StatusRecurring, + Description: "Weekly task", + Priority: PriorityHigh, + Created: time.Now(), + Modified: time.Now(), + Due: &dueDate, + RecurrenceDuration: &duration, + ParentUUID: nil, + Tags: []string{}, + } + + if err := template.Save(); err != nil { + t.Fatalf("Failed to save template: %v", err) + } + + if err := template.AddTag("work"); err != nil { + t.Fatalf("Failed to add tag: %v", err) + } + + // Create and complete first instance + instance1 := &Task{ + UUID: uuid.New(), + Status: StatusPending, + Description: template.Description, + Priority: template.Priority, + Created: time.Now(), + Modified: time.Now(), + Due: template.Due, + ParentUUID: &template.UUID, + Tags: []string{}, + } + + if err := instance1.Save(); err != nil { + t.Fatalf("Failed to save instance: %v", err) + } + + if err := instance1.AddTag("work"); err != nil { + t.Fatalf("Failed to add tag to instance: %v", err) + } + + // Complete the instance (should spawn next) + if err := instance1.Complete(); err != nil { + t.Fatalf("Failed to complete instance: %v", err) + } + + // Find the newly spawned instance + filter := DefaultFilter() + tasks, err := GetTasks(filter) + if err != nil { + t.Fatalf("Failed to get tasks: %v", err) + } + + // Should have at least one new pending instance + found := false + for _, task := range tasks { + if task.ParentUUID != nil && *task.ParentUUID == template.UUID && task.Status == StatusPending { + found = true + + // Verify due date was advanced + if task.Due == nil { + t.Error("New instance should have due date") + } else { + expectedDue := CalculateNextDue(*instance1.Due, duration) + // Allow 1 second tolerance due to Unix timestamp precision + diff := task.Due.Sub(expectedDue) + if diff < -time.Second || diff > time.Second { + t.Errorf("Expected due date %v, got %v (diff: %v)", expectedDue, *task.Due, diff) + } + } + + // Verify tags were copied + if len(task.Tags) == 0 { + t.Error("Tags should be copied to new instance") + } + + foundTag := false + for _, tag := range task.Tags { + if tag == "work" { + foundTag = true + } + } + if !foundTag { + t.Error("'work' tag should be copied to new instance") + } + + break + } + } + + if !found { + t.Error("Should have spawned a new instance after completing recurring task") + } +} + +func TestRecurrenceWithUntilDate(t *testing.T) { + // Create template with until date very soon + dueDate := time.Now().Add(-24 * time.Hour) // Already past + until := time.Now().Add(-12 * time.Hour) // Until date also past + duration := 7 * 24 * time.Hour + + template := &Task{ + UUID: uuid.New(), + Status: StatusRecurring, + Description: "Expired recurring task", + Priority: PriorityMedium, + Created: time.Now(), + Modified: time.Now(), + Due: &dueDate, + Until: &until, + RecurrenceDuration: &duration, + ParentUUID: nil, + Tags: []string{}, + } + + if err := template.Save(); err != nil { + t.Fatalf("Failed to save template: %v", err) + } + + // Create instance + instance := &Task{ + UUID: uuid.New(), + Status: StatusPending, + Description: template.Description, + Priority: template.Priority, + Created: time.Now(), + Modified: time.Now(), + Due: template.Due, + Until: template.Until, + ParentUUID: &template.UUID, + Tags: []string{}, + } + + if err := instance.Save(); err != nil { + t.Fatalf("Failed to save instance: %v", err) + } + + // Count pending instances before + filter := DefaultFilter() + beforeTasks, _ := GetTasks(filter) + beforeCount := 0 + for _, task := range beforeTasks { + if task.ParentUUID != nil && *task.ParentUUID == template.UUID && task.Status == StatusPending { + beforeCount++ + } + } + + // Complete instance - should NOT spawn new one (past until date) + if err := instance.Complete(); err != nil { + t.Fatalf("Failed to complete instance: %v", err) + } + + // Count pending instances after + afterTasks, _ := GetTasks(filter) + afterCount := 0 + for _, task := range afterTasks { + if task.ParentUUID != nil && *task.ParentUUID == template.UUID && task.Status == StatusPending { + afterCount++ + } + } + + // Should have one less (the one we completed) and no new one spawned + if afterCount != beforeCount-1 { + t.Errorf("Expected %d pending instances after, got %d (should not spawn due to until date)", beforeCount-1, afterCount) + } +}