Files
gems/opal-task/internal/engine/recurrence.go
T
joakim cb4b7ac14b Implement opal-task Phase 5: Recurrence Implementation
- 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
2026-01-04 18:13:32 +01:00

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
}