78881e1b07
Extract CreateRecurringTask into engine package for reuse by both CLI and API. Add POST /tasks/parse endpoint for CLI-style input parsing. Remove FK constraint on change_log to preserve history after task deletion. Update web frontend to filter completed tasks from view and add mock mode support for development. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
263 lines
7.3 KiB
Go
263 lines
7.3 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
|
|
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
|
|
}
|