package engine import ( "fmt" "strconv" "time" "github.com/google/uuid" ) // ParseRecurrencePattern converts duration strings to time.Duration // Supports: 1d, 2w, 3m, 1y, 5min, 5hrs, 30sec // Also supports: days, weeks, months, years, minutes, hours, seconds // And common forms: daily, weekly, monthly, yearly func ParseRecurrencePattern(pattern string) (time.Duration, error) { if len(pattern) == 0 { return 0, fmt.Errorf("empty duration pattern") } // Handle word-based durations (hour, hours, day, days, etc.) wordDurations := map[string]time.Duration{ "second": time.Second, "seconds": time.Second, "sec": time.Second, "minute": time.Minute, "minutes": time.Minute, "min": time.Minute, "hour": time.Hour, "hours": time.Hour, "hrs": time.Hour, "hr": time.Hour, "day": 24 * time.Hour, "days": 24 * time.Hour, "daily": 24 * time.Hour, "week": 7 * 24 * time.Hour, "weeks": 7 * 24 * time.Hour, "weekly": 7 * 24 * time.Hour, "month": 30 * 24 * time.Hour, "months": 30 * 24 * time.Hour, "monthly": 30 * 24 * time.Hour, "year": 365 * 24 * time.Hour, "years": 365 * 24 * time.Hour, "yearly": 365 * 24 * time.Hour, } if duration, ok := wordDurations[pattern]; ok { return duration, nil } // Find where number ends and unit begins splitIdx := -1 for i, ch := range pattern { if (ch < '0' || ch > '9') && ch != '-' { splitIdx = i break } } if splitIdx == -1 || splitIdx == 0 { return 0, fmt.Errorf("invalid duration pattern: %s", pattern) } numStr := pattern[:splitIdx] unit := pattern[splitIdx:] num, err := strconv.Atoi(numStr) if err != nil { return 0, fmt.Errorf("invalid number in pattern: %s", pattern) } // Single letter units if len(unit) == 1 { switch unit { case "d": return time.Duration(num) * 24 * time.Hour, nil case "w": return time.Duration(num) * 7 * 24 * time.Hour, nil case "m": // Approximate: 30 days return time.Duration(num) * 30 * 24 * time.Hour, nil case "y": // Approximate: 365 days return time.Duration(num) * 365 * 24 * time.Hour, nil default: return 0, fmt.Errorf("unknown unit: %s (use d/w/m/y)", unit) } } // Multi-character units if baseDuration, ok := wordDurations[unit]; ok { return time.Duration(num) * baseDuration, nil } return 0, fmt.Errorf("unknown duration unit: %s", unit) } // FormatRecurrenceDuration converts time.Duration back to "1w", "2d" format func FormatRecurrenceDuration(d time.Duration) string { days := int(d.Hours() / 24) if days%365 == 0 && days/365 > 0 { return fmt.Sprintf("%dy", days/365) } if days%30 == 0 && days/30 > 0 { return fmt.Sprintf("%dm", days/30) } if days%7 == 0 && days/7 > 0 { return fmt.Sprintf("%dw", days/7) } return fmt.Sprintf("%dd", days) } // CalculateNextDue calculates next due date based on current and recurrence func CalculateNextDue(currentDue time.Time, recurrence time.Duration) time.Time { return currentDue.Add(recurrence) } // CreateRecurringTask creates a recurring task template and its first instance. // It validates the recurrence pattern and due date, creates the template with // StatusRecurring, then creates the first pending instance linked to it. // Returns the first instance task. func CreateRecurringTask(description string, mod *Modifier) (*Task, error) { recurPattern := mod.SetAttributes["recur"] if recurPattern == nil { return nil, fmt.Errorf("no recurrence pattern specified") } if mod.SetAttributes["due"] == nil { return nil, fmt.Errorf("recurring tasks require a due date (use due:YYYY-MM-DD or due:monday)") } duration, err := ParseRecurrencePattern(*recurPattern) if err != nil { return nil, fmt.Errorf("invalid recurrence pattern: %w", err) } now := time.Now() template := &Task{ UUID: uuid.New(), Status: StatusRecurring, Description: description, Priority: PriorityDefault, Created: now, Modified: now, RecurrenceDuration: &duration, Tags: []string{}, } // Build modifier without the recur attribute tempMod := &Modifier{ SetAttributes: make(map[string]*string), AttributeOrder: []string{}, AddTags: mod.AddTags, RemoveTags: mod.RemoveTags, } for _, key := range mod.AttributeOrder { if key != "recur" { tempMod.SetAttributes[key] = mod.SetAttributes[key] tempMod.AttributeOrder = append(tempMod.AttributeOrder, key) } } if err := tempMod.ApplyToNew(template); err != nil { return nil, fmt.Errorf("failed to apply modifiers to template: %w", err) } if err := template.Save(); err != nil { return nil, fmt.Errorf("failed to save template: %w", err) } for _, tag := range mod.AddTags { if err := template.AddTag(tag); err != nil { return nil, fmt.Errorf("failed to add tag to template: %w", err) } } // Create first instance instance := &Task{ UUID: uuid.New(), Status: StatusPending, Description: description, Priority: template.Priority, Created: now, Modified: now, ParentUUID: &template.UUID, Due: template.Due, Wait: template.Wait, Scheduled: template.Scheduled, Project: template.Project, Tags: []string{}, } if err := instance.Save(); err != nil { return nil, fmt.Errorf("failed to save first instance: %w", err) } for _, tag := range template.Tags { if err := instance.AddTag(tag); err != nil { return nil, fmt.Errorf("failed to add tag to instance: %w", err) } } return instance, nil } // SpawnNextInstance creates a new task instance from completed recurring task. // Returns the newly created instance, or nil if recurrence has expired. func SpawnNextInstance(completedInstance *Task) (*Task, error) { if completedInstance.ParentUUID == nil { return nil, fmt.Errorf("task is not a recurring instance") } // Load template template, err := GetTask(*completedInstance.ParentUUID) if err != nil { return nil, fmt.Errorf("failed to load template: %w", err) } if template.RecurrenceDuration == nil { return nil, fmt.Errorf("template has no recurrence duration") } // Calculate next due date // Use End date (completion time) as base, fallback to Due date if End not set var baseDate time.Time if completedInstance.End != nil { baseDate = *completedInstance.End } else if completedInstance.Due != nil { baseDate = *completedInstance.Due } else { return nil, fmt.Errorf("recurring instance has no due or end date") } next := CalculateNextDue(baseDate, *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, 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 nil, fmt.Errorf("failed to save new instance: %w", err) } // Copy tags from template templateTags, err := template.GetTags() if err != nil { return nil, fmt.Errorf("failed to get template tags: %w", err) } for _, tag := range templateTags { if err := newInstance.AddTag(tag); err != nil { return nil, fmt.Errorf("failed to add tag: %w", err) } } return newInstance, nil }