diff --git a/opal-task/internal/engine/dateparse.go b/opal-task/internal/engine/dateparse.go index 9daf2be..ec9d63a 100644 --- a/opal-task/internal/engine/dateparse.go +++ b/opal-task/internal/engine/dateparse.go @@ -48,6 +48,11 @@ func (p *DateParser) ParseDate(s string) (time.Time, error) { // parseDateOnly parses just the date component without time func (p *DateParser) parseDateOnly(s string) (time.Time, error) { + // Empty string means "today" (used when parsing just time like "15:35") + if s == "" { + return time.Date(p.base.Year(), p.base.Month(), p.base.Day(), 0, 0, 0, 0, p.base.Location()), nil + } + // Try ISO format first if t, err := time.Parse("2006-01-02", s); err == nil { return t, nil @@ -102,27 +107,47 @@ func (p *DateParser) splitDateTime(s string) (string, string, bool) { return s, "", false } - // Check last part for time pattern (2-4 digits) lastPart := parts[len(parts)-1] - if len(lastPart) == 2 || len(lastPart) == 4 { - // Could be minutes (HH:MM) or HHMM format - if _, err := strconv.Atoi(lastPart); err == nil { - // Last part is numeric, could be time - if len(parts) == 2 { - // Format: "15:35" (just time, no date) - return "", s, true - } else if len(parts) >= 3 { - // Format: "mon:15:35" or similar - secondLast := parts[len(parts)-2] - if _, err := strconv.Atoi(secondLast); err == nil { - // HH:MM format - dateStr := strings.Join(parts[:len(parts)-2], ":") - timeStr := secondLast + ":" + lastPart - return dateStr, timeStr, true - } else if len(lastPart) == 4 { - // HHMM format - dateStr := strings.Join(parts[:len(parts)-1], ":") - return dateStr, lastPart, true + + // Format: "mon:15:35" or "tomorrow:15:35" (HH:MM with 3+ parts) + if len(parts) >= 3 { + // Check if last two parts form a valid time + secondLast := parts[len(parts)-2] + if h, err := strconv.Atoi(secondLast); err == nil && h >= 0 && h <= 23 { + if m, err := strconv.Atoi(lastPart); err == nil && m >= 0 && m <= 59 { + // HH:MM format + dateStr := strings.Join(parts[:len(parts)-2], ":") + if dateStr == "" { + dateStr = "today" // Just time means today + } + timeStr := secondLast + ":" + lastPart + return dateStr, timeStr, true + } + } + } + + // Format with 2 parts: could be "15:35" (just time) or "mon:0800" (date+HHMM) + if len(parts) == 2 { + // Check if lastPart is HHMM format (4 digits) + if len(lastPart) == 4 { + if _, err := strconv.Atoi(lastPart); err == nil { + h, _ := strconv.Atoi(lastPart[0:2]) + m, _ := strconv.Atoi(lastPart[2:4]) + if h >= 0 && h <= 23 && m >= 0 && m <= 59 { + // Valid HHMM time + // If first part is also numeric, it's a date part (like "21jan:0800" but already lowercase) + // Otherwise it's a keyword like "tomorrow:0800" + return parts[0], lastPart, true + } + } + } + + // Check if it's "HH:MM" format (both parts are 2-digit numbers) + if len(parts[0]) <= 2 && len(lastPart) <= 2 { + if h, err := strconv.Atoi(parts[0]); err == nil && h >= 0 && h <= 23 { + if m, err := strconv.Atoi(lastPart); err == nil && m >= 0 && m <= 59 { + // Just time, no date + return "", parts[0] + ":" + lastPart, true } } } @@ -213,32 +238,223 @@ func (p *DateParser) parseWeekday(s string) (time.Time, bool) { // parseMonthName handles month names (jan, january, feb, february, etc.) func (p *DateParser) parseMonthName(s string) (time.Time, bool) { - // Placeholder for Phase 2 + months := map[string]time.Month{ + "jan": time.January, "january": time.January, + "feb": time.February, "february": time.February, + "mar": time.March, "march": time.March, + "apr": time.April, "april": time.April, + "may": time.May, + "jun": time.June, "june": time.June, + "jul": time.July, "july": time.July, + "aug": time.August, "august": time.August, + "sep": time.September, "september": time.September, + "oct": time.October, "october": time.October, + "nov": time.November, "november": time.November, + "dec": time.December, "december": time.December, + } + + month, ok := months[s] + if !ok { + return time.Time{}, false + } + + // Set to first of month + // If that date has passed this year, use next year + year := p.base.Year() + firstOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, p.base.Location()) + + if firstOfMonth.Before(p.base) || firstOfMonth.Equal(p.base) { + // First of month has passed, use next year + year++ + firstOfMonth = time.Date(year, month, 1, 0, 0, 0, 0, p.base.Location()) + } + + return firstOfMonth, true +} + +// parseDayMonth handles day+month formats (21jan, Jan21, 21January, January21) +func (p *DateParser) parseDayMonth(s string) (time.Time, bool) { + // Try pattern: digits followed by month name (21jan, 21January) + for i := 0; i < len(s); i++ { + if s[i] >= '0' && s[i] <= '9' { + continue + } + // Found first non-digit + if i > 0 && i < len(s) { + dayStr := s[:i] + monthStr := strings.ToLower(s[i:]) + if day, month, ok := p.parseDayAndMonth(dayStr, monthStr); ok { + return p.constructDate(day, month), true + } + } + break + } + + // Try pattern: month name followed by digits (jan21, January21) + for i := 0; i < len(s); i++ { + if s[i] >= 'a' && s[i] <= 'z' || s[i] >= 'A' && s[i] <= 'Z' { + continue + } + // Found first non-letter + if i > 0 && i < len(s) { + monthStr := strings.ToLower(s[:i]) + dayStr := s[i:] + if day, month, ok := p.parseDayAndMonth(dayStr, monthStr); ok { + return p.constructDate(day, month), true + } + } + break + } + return time.Time{}, false } -// parseDayMonth handles day+month formats (21jan, Jan21, etc.) -func (p *DateParser) parseDayMonth(s string) (time.Time, bool) { - // Placeholder for Phase 2 - return time.Time{}, false +// parseDayAndMonth extracts day and month from string parts +func (p *DateParser) parseDayAndMonth(dayStr, monthStr string) (int, time.Month, bool) { + day, err := strconv.Atoi(dayStr) + if err != nil || day < 1 || day > 31 { + return 0, 0, false + } + + months := map[string]time.Month{ + "jan": time.January, "january": time.January, + "feb": time.February, "february": time.February, + "mar": time.March, "march": time.March, + "apr": time.April, "april": time.April, + "may": time.May, + "jun": time.June, "june": time.June, + "jul": time.July, "july": time.July, + "aug": time.August, "august": time.August, + "sep": time.September, "september": time.September, + "oct": time.October, "october": time.October, + "nov": time.November, "november": time.November, + "dec": time.December, "december": time.December, + } + + month, ok := months[monthStr] + if !ok { + return 0, 0, false + } + + return day, month, true +} + +// constructDate creates a date with year logic (current year if future, next year if past) +func (p *DateParser) constructDate(day int, month time.Month) time.Time { + year := p.base.Year() + date := time.Date(year, month, day, 0, 0, 0, 0, p.base.Location()) + + // Validate day is valid for month (handles Feb 30, etc.) + if date.Month() != month { + // Invalid day for month, return zero time (will be caught as parse error) + return time.Time{} + } + + if date.Before(p.base) || date.Equal(p.base) { + // Date has passed, use next year + year++ + date = time.Date(year, month, day, 0, 0, 0, 0, p.base.Location()) + } + + return date } // parsePeriodBoundary handles period boundary keywords (sod, eod, sow, eow, etc.) func (p *DateParser) parsePeriodBoundary(s string) (time.Time, bool) { - // Placeholder for Phase 2 - return time.Time{}, false + now := p.base + loc := now.Location() + + switch s { + case "sod": + // Start of day - today at 00:00:00 + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc), true + + case "eod": + // End of day - today at 23:59:59 + return time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, loc), true + + case "sow": + // Start of week - next occurrence of week start day at 00:00:00 + return p.nextWeekday(p.weekStart), true + + case "eow": + // End of week - next occurrence of week end day at 23:59:59 + weekEnd := p.weekStart - 1 + if weekEnd < 0 { + weekEnd = 6 + } + endDate := p.nextWeekday(weekEnd) + return time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 23, 59, 59, 0, loc), true + + case "som": + // Start of month - 1st of current month at 00:00:00 + // If we're past the 1st, use next month + firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc) + if firstOfMonth.Before(now) || firstOfMonth.Equal(now) { + // Already past the 1st, use next month + firstOfMonth = time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, loc) + } + return firstOfMonth, true + + case "eom": + // End of month - last day of current month at 23:59:59 + // Get first day of next month, then subtract one day + firstOfNextMonth := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, loc) + lastOfMonth := firstOfNextMonth.AddDate(0, 0, -1) + return time.Date(lastOfMonth.Year(), lastOfMonth.Month(), lastOfMonth.Day(), 23, 59, 59, 0, loc), true + + case "soy": + // Start of year - Jan 1 of current year at 00:00:00 + // If we're past Jan 1, use next year + firstOfYear := time.Date(now.Year(), time.January, 1, 0, 0, 0, 0, loc) + if firstOfYear.Before(now) || firstOfYear.Equal(now) { + firstOfYear = time.Date(now.Year()+1, time.January, 1, 0, 0, 0, 0, loc) + } + return firstOfYear, true + + case "eoy": + // End of year - Dec 31 of current year at 23:59:59 + return time.Date(now.Year(), time.December, 31, 23, 59, 59, 0, loc), true + + default: + return time.Time{}, false + } } // parseSpecialKeyword handles special keywords (later, someday) func (p *DateParser) parseSpecialKeyword(s string) (time.Time, bool) { - // Placeholder for Phase 2 - return time.Time{}, false + switch s { + case "later", "someday": + // Far future: 2150-01-01 at 00:00:00 + return time.Date(2150, time.January, 1, 0, 0, 0, 0, p.base.Location()), true + default: + return time.Time{}, false + } } -// parseDurationAsDate handles duration as date offset (2d, 3w, etc.) +// parseDurationAsDate handles duration as date offset (2d, 3w, etc.) and named durations func (p *DateParser) parseDurationAsDate(s string) (time.Time, bool) { - // Placeholder for Phase 2 - return time.Time{}, false + // Named duration aliases + namedDurations := map[string]string{ + "daily": "1d", + "weekly": "1w", + "monthly": "30d", + "yearly": "1y", + } + + // Check for named duration + if pattern, ok := namedDurations[s]; ok { + s = pattern + } + + // Try parsing as duration + duration, err := ParseRecurrencePattern(s) + if err != nil { + return time.Time{}, false + } + + // Add duration to base time + return p.base.Add(duration), true } // ParseDuration parses duration strings (1d, 2w, 5min, etc.) diff --git a/opal-task/internal/engine/dateparse_test.go b/opal-task/internal/engine/dateparse_test.go new file mode 100644 index 0000000..80d8f6c --- /dev/null +++ b/opal-task/internal/engine/dateparse_test.go @@ -0,0 +1,260 @@ +package engine + +import ( + "testing" + "time" +) + +func TestParseMonthName(t *testing.T) { + // Base: Jan 5, 2026 + base := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC) + parser := NewDateParser(base, time.Monday) + + tests := []struct { + name string + input string + expected time.Time + }{ + {"jan (passed)", "jan", time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)}, + {"january (passed)", "january", time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)}, + {"feb (future)", "feb", time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)}, + {"february (future)", "february", time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)}, + {"mar", "mar", time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)}, + {"march", "march", time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)}, + {"dec", "dec", time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC)}, + {"december", "december", time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, ok := parser.parseMonthName(tt.input) + if !ok { + t.Fatalf("Failed to parse month name: %s", tt.input) + } + if !result.Equal(tt.expected) { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestParseDayMonth(t *testing.T) { + // Base: Jan 5, 2026 + base := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC) + parser := NewDateParser(base, time.Monday) + + tests := []struct { + name string + input string + expected time.Time + }{ + {"21jan (future this year)", "21jan", time.Date(2026, 1, 21, 0, 0, 0, 0, time.UTC)}, + {"21Jan", "21Jan", time.Date(2026, 1, 21, 0, 0, 0, 0, time.UTC)}, + {"Jan21", "Jan21", time.Date(2026, 1, 21, 0, 0, 0, 0, time.UTC)}, + {"jan21", "jan21", time.Date(2026, 1, 21, 0, 0, 0, 0, time.UTC)}, + {"1jan (passed)", "1jan", time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)}, + {"30dec", "30dec", time.Date(2026, 12, 30, 0, 0, 0, 0, time.UTC)}, + {"15feb", "15feb", time.Date(2026, 2, 15, 0, 0, 0, 0, time.UTC)}, + {"15February", "15February", time.Date(2026, 2, 15, 0, 0, 0, 0, time.UTC)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, ok := parser.parseDayMonth(tt.input) + if !ok { + t.Fatalf("Failed to parse day+month: %s", tt.input) + } + if !result.Equal(tt.expected) { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestParsePeriodBoundary(t *testing.T) { + // Base: Monday, Jan 5, 2026, 14:30:00 + base := time.Date(2026, 1, 5, 14, 30, 0, 0, time.UTC) + parser := NewDateParser(base, time.Monday) + + tests := []struct { + name string + input string + expected time.Time + }{ + {"sod", "sod", time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)}, + {"eod", "eod", time.Date(2026, 1, 5, 23, 59, 59, 0, time.UTC)}, + {"sow", "sow", time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC)}, // Next Monday + {"eow", "eow", time.Date(2026, 1, 11, 23, 59, 59, 0, time.UTC)}, // Next Sunday + {"som", "som", time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)}, // Next month (Jan 1 passed) + {"eom", "eom", time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC)}, // End of Jan + {"soy", "soy", time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)}, // Next year (Jan 1 passed) + {"eoy", "eoy", time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC)}, // End of 2026 + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, ok := parser.parsePeriodBoundary(tt.input) + if !ok { + t.Fatalf("Failed to parse period boundary: %s", tt.input) + } + if !result.Equal(tt.expected) { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestParseSpecialKeyword(t *testing.T) { + base := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC) + parser := NewDateParser(base, time.Monday) + + tests := []struct { + name string + input string + expected time.Time + }{ + {"later", "later", time.Date(2150, 1, 1, 0, 0, 0, 0, time.UTC)}, + {"someday", "someday", time.Date(2150, 1, 1, 0, 0, 0, 0, time.UTC)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, ok := parser.parseSpecialKeyword(tt.input) + if !ok { + t.Fatalf("Failed to parse special keyword: %s", tt.input) + } + if !result.Equal(tt.expected) { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestParseDurationAsDate(t *testing.T) { + base := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC) + parser := NewDateParser(base, time.Monday) + + tests := []struct { + name string + input string + expected time.Time + }{ + {"2d", "2d", base.AddDate(0, 0, 2)}, + {"3w", "3w", base.AddDate(0, 0, 21)}, + {"1m", "1m", base.AddDate(0, 0, 30)}, + {"1y", "1y", base.AddDate(0, 0, 365)}, + {"daily", "daily", base.AddDate(0, 0, 1)}, + {"weekly", "weekly", base.AddDate(0, 0, 7)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, ok := parser.parseDurationAsDate(tt.input) + if !ok { + t.Fatalf("Failed to parse duration as date: %s", tt.input) + } + if !result.Equal(tt.expected) { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestParseTimeOfDay(t *testing.T) { + base := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC) + parser := NewDateParser(base, time.Monday) + + tests := []struct { + name string + date time.Time + timeStr string + expected time.Time + wantErr bool + }{ + {"HH:MM format", base, "15:35", time.Date(2026, 1, 5, 15, 35, 0, 0, time.UTC), false}, + {"HHMM format", base, "0800", time.Date(2026, 1, 5, 8, 0, 0, 0, time.UTC), false}, + {"midnight", base, "00:00", time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC), false}, + {"end of day", base, "23:59", time.Date(2026, 1, 5, 23, 59, 0, 0, time.UTC), false}, + {"invalid hour", base, "25:00", time.Time{}, true}, + {"invalid minute", base, "12:65", time.Time{}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parser.parseTimeOfDay(tt.date, tt.timeStr) + if tt.wantErr { + if err == nil { + t.Error("Expected error but got none") + } + return + } + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !result.Equal(tt.expected) { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestParseDateWithTime(t *testing.T) { + base := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC) // Monday + parser := NewDateParser(base, time.Monday) + + tests := []struct { + name string + input string + expected time.Time + }{ + {"mon:15:35", "mon:15:35", time.Date(2026, 1, 12, 15, 35, 0, 0, time.UTC)}, // Next Monday at 15:35 + {"tomorrow:0800", "tomorrow:0800", time.Date(2026, 1, 6, 8, 0, 0, 0, time.UTC)}, + {"21jan:1430", "21jan:1430", time.Date(2026, 1, 21, 14, 30, 0, 0, time.UTC)}, + {"just time 15:35", "15:35", time.Date(2026, 1, 5, 15, 35, 0, 0, time.UTC)}, // Today at 15:35 + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parser.ParseDate(tt.input) + if err != nil { + t.Fatalf("Failed to parse date with time: %v", err) + } + if !result.Equal(tt.expected) { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestExpandedDurationFormats(t *testing.T) { + tests := []struct { + name string + input string + expected time.Duration + }{ + {"5sec", "5sec", 5 * time.Second}, + {"5seconds", "5seconds", 5 * time.Second}, + {"10min", "10min", 10 * time.Minute}, + {"10minutes", "10minutes", 10 * time.Minute}, + {"2hrs", "2hrs", 2 * time.Hour}, + {"2hours", "2hours", 2 * time.Hour}, + {"3days", "3days", 3 * 24 * time.Hour}, + {"2weeks", "2weeks", 2 * 7 * 24 * time.Hour}, + {"1month", "1month", 30 * 24 * time.Hour}, + {"1year", "1year", 365 * 24 * time.Hour}, + {"hour (no number)", "hour", time.Hour}, + {"day (no number)", "day", 24 * time.Hour}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseRecurrencePattern(tt.input) + if err != nil { + t.Fatalf("Failed to parse duration: %v", err) + } + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} diff --git a/opal-task/internal/engine/recurrence.go b/opal-task/internal/engine/recurrence.go index 0ea2b06..d799636 100644 --- a/opal-task/internal/engine/recurrence.go +++ b/opal-task/internal/engine/recurrence.go @@ -8,34 +8,74 @@ import ( "github.com/google/uuid" ) -// ParseRecurrencePattern converts "1w", "2d", "1m" to time.Duration +// ParseRecurrencePattern converts duration strings to time.Duration +// Supports: 1d, 2w, 3m, 1y, 5min, 5hrs, 30sec +// Also supports: days, weeks, months, years, minutes, hours, seconds func ParseRecurrencePattern(pattern string) (time.Duration, error) { - if len(pattern) < 2 { - return 0, fmt.Errorf("invalid recurrence pattern: %s", pattern) + if len(pattern) == 0 { + return 0, fmt.Errorf("empty duration pattern") } - numStr := pattern[:len(pattern)-1] - unit := pattern[len(pattern)-1] + // 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, + "week": 7 * 24 * time.Hour, "weeks": 7 * 24 * time.Hour, + "month": 30 * 24 * time.Hour, "months": 30 * 24 * time.Hour, + "year": 365 * 24 * time.Hour, "years": 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) } - 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: %c (use d/w/m/y)", unit) + // 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