Files
gems/opal-task/internal/engine/recurrence.go
T
joakim b02c40f716 feat: improve CLI output with relative dates, rich feedback, and recurring task info
Add relative date formatting (today, tomorrow, in 3d, etc.) for list and
detail views. Add structured feedback helpers for add/complete/delete
operations showing display IDs and parsed modifiers. Change Complete() to
return spawned recurring instance so callers can display recurrence info.
Add AppendTask to working set for immediate display ID assignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:44:56 +01:00

264 lines
7.4 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)
}
// 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
}