a68d701d14
Issue 1: Fix recurrence calculation for overdue tasks - Use completion date (End) as base for next instance, not original due date - If task due Monday completed Wednesday, next is Wednesday+7d not Monday+7d - Fallback to Due date if End is not set - Update test to verify new behavior Issue 2: Fix description parsing to work without quotes - Add parseAddArgs() to extract description from non-modifier words - Description = all words that don't start with +, -, or contain : - Enables: opal add buy groceries +shop carrots → 'buy groceries carrots' - Validate description is required (error if only modifiers) - Validate recurring tasks require due date Issue 3: Implement flexible command syntax - Add preprocessArgs() to parse arguments before Cobra routing - Detect command position and split filters (left) from modifiers (right) - Rewrite os.Args so Cobra routes correctly - Enable both 'opal 2 done' and 'opal done 2' syntax - Commands without modifiers accept filters on either side - Commands with modifiers enforce [filters] command [modifiers] - Add confirmation for modify without filters (modifies all tasks) All commands updated to use preprocessed ParsedArgs from context. All tests passing (33 tests).
134 lines
3.4 KiB
Go
134 lines
3.4 KiB
Go
package engine
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// ParseRecurrencePattern converts "1w", "2d", "1m" to time.Duration
|
|
func ParseRecurrencePattern(pattern string) (time.Duration, error) {
|
|
if len(pattern) < 2 {
|
|
return 0, fmt.Errorf("invalid recurrence pattern: %s", pattern)
|
|
}
|
|
|
|
numStr := pattern[:len(pattern)-1]
|
|
unit := pattern[len(pattern)-1]
|
|
|
|
num, err := strconv.Atoi(numStr)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid number in pattern: %s", pattern)
|
|
}
|
|
|
|
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: %c (use d/w/m/y)", 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
|
|
}
|