d0b46beeec
Previously, only numeric forms (1d, 1w, 1m, 1y) and plural word forms (days, weeks, months, years) were supported for recurrence patterns. The common adverbial forms (daily, weekly, monthly, yearly) would fail with 'invalid recurrence pattern' error. Now users can use natural language recurrence patterns: - recur:daily -> 1 day interval - recur:weekly -> 7 day interval - recur:monthly -> 30 day interval - recur:yearly -> 365 day interval Added test coverage for all four new patterns.
175 lines
4.8 KiB
Go
175 lines
4.8 KiB
Go
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)
|
|
}
|
|
|
|
// SpawnNextInstance creates a new task instance from completed recurring task
|
|
func SpawnNextInstance(completedInstance *Task) error {
|
|
if completedInstance.ParentUUID == nil {
|
|
return fmt.Errorf("task is not a recurring instance")
|
|
}
|
|
|
|
// 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
|
|
// 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 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
|
|
}
|
|
|
|
// 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
|
|
}
|