b02c40f716
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>
264 lines
7.4 KiB
Go
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
|
|
}
|