cb4b7ac14b
- Complete SpawnNextInstance() for creating recurring task instances - Implement automatic next instance spawning on task completion - Add support for 'until' date to expire recurrences - Copy tags from template to new instances - Add comprehensive recurrence tests (6 tests, all passing) - Test pattern parsing, formatting, next due calculation - Test end-to-end recurring task workflow - Test expiration with until dates
127 lines
3.2 KiB
Go
127 lines
3.2 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
|
|
var nextDue *time.Time
|
|
if completedInstance.Due != nil {
|
|
next := CalculateNextDue(*completedInstance.Due, *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
|
|
}
|