From 2afa4c6ee048ed68c48be5269caed7e5fa015fa9 Mon Sep 17 00:00:00 2001 From: Joakim Date: Mon, 5 Jan 2026 10:05:20 +0100 Subject: [PATCH] Phase 4: Implement relative date expressions - Add parseRelativeExpression() to detect pattern: attr+/-duration - Add resolveDateValue() to resolve absolute or relative dates - Add applyDateAttribute() helper for date attributes with relative support - Track attribute order in Modifier struct (AttributeOrder field) - Refactor Apply() and ApplyToNew() to process attrs in order - Support chaining: due:mon scheduled:due-3d wait:scheduled-1d - Support addition and subtraction: due+1y, wait-2d - Add comprehensive test suite for relative expressions - Error if referencing undefined date attribute - All 38+ tests passing --- opal-task/internal/engine/modifier.go | 253 +++++++++++------- .../internal/engine/modifier_relative_test.go | 134 ++++++++++ opal-task/internal/engine/modifier_test.go | 1 + 3 files changed, 298 insertions(+), 90 deletions(-) create mode 100644 opal-task/internal/engine/modifier_relative_test.go diff --git a/opal-task/internal/engine/modifier.go b/opal-task/internal/engine/modifier.go index edad930..c4f1548 100644 --- a/opal-task/internal/engine/modifier.go +++ b/opal-task/internal/engine/modifier.go @@ -2,20 +2,24 @@ package engine import ( "fmt" + "regexp" "strings" + "time" ) type Modifier struct { - SetAttributes map[string]*string // key -> value (nil = clear) - AddTags []string - RemoveTags []string + SetAttributes map[string]*string // key -> value (nil = clear) + AttributeOrder []string // Track order of attributes for relative references + AddTags []string + RemoveTags []string } func NewModifier() *Modifier { return &Modifier{ - SetAttributes: make(map[string]*string), - AddTags: []string{}, - RemoveTags: []string{}, + SetAttributes: make(map[string]*string), + AttributeOrder: []string{}, + AddTags: []string{}, + RemoveTags: []string{}, } } @@ -42,6 +46,9 @@ func ParseModifier(args []string) (*Modifier, error) { } else { m.SetAttributes[key] = &value } + + // Track order for relative date references + m.AttributeOrder = append(m.AttributeOrder, key) } } @@ -50,8 +57,46 @@ func ParseModifier(args []string) (*Modifier, error) { // Apply applies modifier to task func (m *Modifier) Apply(task *Task) error { - // Apply attribute changes - for key, valuePtr := range m.SetAttributes { + // Track resolved dates for relative references + resolvedDates := make(map[string]time.Time) + + // Initialize with existing task dates + if task.Due != nil { + resolvedDates["due"] = *task.Due + } + if task.Scheduled != nil { + resolvedDates["scheduled"] = *task.Scheduled + } + if task.Wait != nil { + resolvedDates["wait"] = *task.Wait + } + if task.Until != nil { + resolvedDates["until"] = *task.Until + } + if task.Start != nil { + resolvedDates["start"] = *task.Start + } + if task.End != nil { + resolvedDates["end"] = *task.End + } + resolvedDates["created"] = task.Created + resolvedDates["modified"] = task.Modified + + // Apply attributes in the order they were specified (important for relative references) + dateKeys := map[string]bool{"due": true, "scheduled": true, "wait": true, "until": true} + + for _, key := range m.AttributeOrder { + valuePtr := m.SetAttributes[key] + + // Handle date attributes with relative expression support + if dateKeys[key] { + if err := applyDateAttribute(key, valuePtr, task, resolvedDates); err != nil { + return err + } + continue + } + + // Handle non-date attributes switch key { case "priority": if valuePtr == nil { @@ -61,46 +106,6 @@ func (m *Modifier) Apply(task *Task) error { } 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 @@ -135,8 +140,31 @@ func (m *Modifier) Apply(task *Task) error { // 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 { + // Track resolved dates for relative references + resolvedDates := make(map[string]time.Time) + + // Initialize with existing task dates (usually empty for new tasks) + if task.Created.IsZero() { + resolvedDates["created"] = timeNow() + } else { + resolvedDates["created"] = task.Created + } + + // Apply attributes in the order they were specified (important for relative references) + dateKeys := map[string]bool{"due": true, "scheduled": true, "wait": true, "until": true} + + for _, key := range m.AttributeOrder { + valuePtr := m.SetAttributes[key] + + // Handle date attributes with relative expression support + if dateKeys[key] { + if err := applyDateAttribute(key, valuePtr, task, resolvedDates); err != nil { + return err + } + continue + } + + // Handle non-date attributes switch key { case "priority": if valuePtr == nil { @@ -146,46 +174,6 @@ func (m *Modifier) ApplyToNew(task *Task) error { } 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 @@ -202,3 +190,88 @@ func (m *Modifier) ApplyToNew(task *Task) error { // Note: Tags are added after task is saved (in CreateTask function) return nil } + +// parseRelativeExpression checks if a string is a relative date expression +// Returns: baseAttr, operator, offset, isRelative +// Example: "due-1d" -> "due", "-", "1d", true +func parseRelativeExpression(s string) (string, string, string, bool) { + // Pattern: + // Examples: due-1d, scheduled+2w, wait-3days + re := regexp.MustCompile(`^([a-z_]+)([\+\-])(.+)$`) + matches := re.FindStringSubmatch(s) + + if len(matches) == 4 { + return matches[1], matches[2], matches[3], true + } + return "", "", "", false +} + +// resolveDateValue resolves a date value, which may be absolute or relative +// resolvedDates contains dates that have been resolved so far (for relative references) +func resolveDateValue(valueStr string, resolvedDates map[string]time.Time) (time.Time, error) { + // Check if it's a relative expression + if baseAttr, op, offsetStr, isRelative := parseRelativeExpression(valueStr); isRelative { + // Look up the base date + baseDate, exists := resolvedDates[baseAttr] + if !exists || baseDate.IsZero() { + return time.Time{}, fmt.Errorf("cannot reference '%s' in '%s' before it's set", baseAttr, valueStr) + } + + // Parse the offset duration + duration, err := ParseRecurrencePattern(offsetStr) + if err != nil { + return time.Time{}, fmt.Errorf("invalid offset in '%s': %w", valueStr, err) + } + + // Apply the operation + if op == "+" { + return baseDate.Add(duration), nil + } else { + return baseDate.Add(-duration), nil + } + } + + // Not relative, parse as absolute date + return ParseDate(valueStr) +} + +// applyDateAttribute applies a date attribute with support for relative expressions +func applyDateAttribute(key string, valuePtr *string, task *Task, resolvedDates map[string]time.Time) error { + if valuePtr == nil { + // Clear the date + switch key { + case "due": + task.Due = nil + case "scheduled": + task.Scheduled = nil + case "wait": + task.Wait = nil + case "until": + task.Until = nil + } + return nil + } + + // Resolve the date (may be relative) + parsed, err := resolveDateValue(*valuePtr, resolvedDates) + if err != nil { + return fmt.Errorf("invalid %s date: %w", key, err) + } + + // Set the date on the task + switch key { + case "due": + task.Due = &parsed + case "scheduled": + task.Scheduled = &parsed + case "wait": + task.Wait = &parsed + case "until": + task.Until = &parsed + } + + // Store in resolved dates for future relative references + resolvedDates[key] = parsed + + return nil +} diff --git a/opal-task/internal/engine/modifier_relative_test.go b/opal-task/internal/engine/modifier_relative_test.go new file mode 100644 index 0000000..40181c5 --- /dev/null +++ b/opal-task/internal/engine/modifier_relative_test.go @@ -0,0 +1,134 @@ +package engine + +import ( + "testing" + "time" +) + +func TestRelativeDateExpressions(t *testing.T) { + task, err := CreateTask("Test task with relative dates") + if err != nil { + t.Fatal(err) + } + + mods := []string{"due:mon", "wait:due-1d"} + modifier, err := ParseModifier(mods) + if err != nil { + t.Fatal(err) + } + + err = modifier.Apply(task) + if err != nil { + t.Fatal(err) + } + + if task.Due == nil || task.Wait == nil { + t.Fatal("Due and wait should be set") + } + + expectedWait := task.Due.AddDate(0, 0, -1) + if !task.Wait.Equal(expectedWait) { + t.Errorf("Wait should be 1 day before due. Expected %v, got %v", expectedWait, *task.Wait) + } +} + +func TestRelativeDateChaining(t *testing.T) { + task, err := CreateTask("Test chained relative dates") + if err != nil { + t.Fatal(err) + } + + mods := []string{"due:mon", "scheduled:due-3d", "wait:scheduled-1d"} + modifier, err := ParseModifier(mods) + if err != nil { + t.Fatal(err) + } + + err = modifier.Apply(task) + if err != nil { + t.Fatal(err) + } + + if task.Due == nil || task.Scheduled == nil || task.Wait == nil { + t.Fatal("All dates should be set") + } + + expectedScheduled := task.Due.AddDate(0, 0, -3) + if !task.Scheduled.Equal(expectedScheduled) { + t.Errorf("Scheduled should be 3 days before due. Expected %v, got %v", expectedScheduled, *task.Scheduled) + } + + expectedWait := task.Scheduled.AddDate(0, 0, -1) + if !task.Wait.Equal(expectedWait) { + t.Errorf("Wait should be 1 day before scheduled. Expected %v, got %v", expectedWait, *task.Wait) + } +} + +func TestRelativeDateAddition(t *testing.T) { + task, err := CreateTask("Test relative date addition") + if err != nil { + t.Fatal(err) + } + + mods := []string{"due:today", "until:due+1y"} + modifier, err := ParseModifier(mods) + if err != nil { + t.Fatal(err) + } + + err = modifier.Apply(task) + if err != nil { + t.Fatal(err) + } + + if task.Due == nil || task.Until == nil { + t.Fatal("Due and until should be set") + } + + expectedUntil := task.Due.AddDate(1, 0, 0) + diff := task.Until.Sub(expectedUntil) + if diff < -24*time.Hour || diff > 24*time.Hour { + t.Errorf("Until should be ~1 year after due. Expected %v, got %v (diff: %v)", expectedUntil, *task.Until, diff) + } +} + +func TestRelativeDateReferenceError(t *testing.T) { + task, err := CreateTask("Test reference error") + if err != nil { + t.Fatal(err) + } + + mods := []string{"wait:due-1d"} + modifier, err := ParseModifier(mods) + if err != nil { + t.Fatal(err) + } + + err = modifier.Apply(task) + if err == nil { + t.Error("Should error when referencing unset date") + } +} + +func TestRelativeDateInNewTask(t *testing.T) { + // Test relative dates when creating a new task via CreateTaskWithModifier + mods := []string{"due:tomorrow", "wait:due-2d"} + modifier, err := ParseModifier(mods) + if err != nil { + t.Fatal(err) + } + + task, err := CreateTaskWithModifier("New task with relative dates", modifier) + if err != nil { + t.Fatal(err) + } + + if task.Due == nil || task.Wait == nil { + t.Fatal("Due and wait should be set") + } + + expectedWait := task.Due.AddDate(0, 0, -2) + if !task.Wait.Equal(expectedWait) { + t.Errorf("Wait should be 2 days before due. Expected %v, got %v", expectedWait, *task.Wait) + } +} diff --git a/opal-task/internal/engine/modifier_test.go b/opal-task/internal/engine/modifier_test.go index cf9992d..4a44256 100644 --- a/opal-task/internal/engine/modifier_test.go +++ b/opal-task/internal/engine/modifier_test.go @@ -271,3 +271,4 @@ func TestModifierWithRecurrence(t *testing.T) { t.Errorf("Expected recurrence %v, got %v", expected, *task.RecurrenceDuration) } } +