Implement opal-task Phase 5: Recurrence Implementation
- Complete SpawnNextInstance() for creating recurring task instances - Implement automatic next instance spawning on task completion - Add support for 'until' date to expire recurrences - Copy tags from template to new instances - Add comprehensive recurrence tests (6 tests, all passing) - Test pattern parsing, formatting, next due calculation - Test end-to-end recurring task workflow - Test expiration with until dates
This commit is contained in:
@@ -4,6 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseRecurrencePattern converts "1w", "2d", "1m" to time.Duration
|
// 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
|
// 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 {
|
func SpawnNextInstance(completedInstance *Task) error {
|
||||||
if completedInstance.ParentUUID == nil {
|
if completedInstance.ParentUUID == nil {
|
||||||
return fmt.Errorf("task is not a recurring instance")
|
return fmt.Errorf("task is not a recurring instance")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement after GetTask is available
|
// Load template
|
||||||
return fmt.Errorf("not implemented yet")
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user