Files
gems/opal-task/internal/engine/recurrence.go
T
joakim cd476cfc99 Phase 3: Implement date format parsers and duration extensions
- Add month name parsing (jan, january, feb, etc.) with year logic
- Add day+month parsing (21jan, Jan21, 21January, etc.) in all case variations
- Add period boundaries (sod, eod, sow, eow, som, eom, soy, eoy)
- Add special keywords (later, someday -> 2150-01-01)
- Add duration-as-date offset (2d, 3w, etc. means X from now)
- Add named duration aliases (daily, weekly, monthly, yearly)
- Enhance ParseRecurrencePattern to support min, sec, hrs and word forms
- Implement time-of-day parsing (mon:15:35, tomorrow:0800, 15:35)
- Add comprehensive test suite (50+ tests total)
- All tests passing
2026-01-05 09:59:46 +01:00

174 lines
4.6 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
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,
"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)
}
// 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
}