Files
gems/opal-task/internal/engine/recurrence.go
T
joakim d0b46beeec Add support for daily, weekly, monthly, yearly recurrence patterns
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.
2026-01-05 10:36:21 +01:00

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
}