// Package categorization implements the Chain of Responsibility pattern for // determining which kanban column a task belongs to. // // The chain evaluates tasks in a specific priority order, with each handler // checking if the task matches its criteria. If a handler matches, it returns // the column name; otherwise, it passes to the next handler in the chain. package categorization import ( "time" "github.com/treytartt/casera-api/internal/models" ) // KanbanColumn represents the possible kanban column names type KanbanColumn string const ( ColumnOverdue KanbanColumn = "overdue_tasks" ColumnDueSoon KanbanColumn = "due_soon_tasks" ColumnUpcoming KanbanColumn = "upcoming_tasks" ColumnInProgress KanbanColumn = "in_progress_tasks" ColumnCompleted KanbanColumn = "completed_tasks" ColumnCancelled KanbanColumn = "cancelled_tasks" ) // String returns the string representation of the column func (c KanbanColumn) String() string { return string(c) } // Context holds the data needed to categorize a task type Context struct { Task *models.Task Now time.Time DaysThreshold int } // NewContext creates a new categorization context with sensible defaults func NewContext(task *models.Task, daysThreshold int) *Context { if daysThreshold <= 0 { daysThreshold = 30 } return &Context{ Task: task, Now: time.Now().UTC(), DaysThreshold: daysThreshold, } } // ThresholdDate returns the date threshold for "due soon" categorization func (c *Context) ThresholdDate() time.Time { return c.Now.AddDate(0, 0, c.DaysThreshold) } // Handler defines the interface for task categorization handlers type Handler interface { // SetNext sets the next handler in the chain SetNext(handler Handler) Handler // Handle processes the task and returns the column name if matched, // or delegates to the next handler Handle(ctx *Context) KanbanColumn } // BaseHandler provides default chaining behavior type BaseHandler struct { next Handler } // SetNext sets the next handler and returns it for fluent chaining func (h *BaseHandler) SetNext(handler Handler) Handler { h.next = handler return handler } // HandleNext delegates to the next handler or returns default func (h *BaseHandler) HandleNext(ctx *Context) KanbanColumn { if h.next != nil { return h.next.Handle(ctx) } return ColumnUpcoming // Default fallback } // === Concrete Handlers === // CancelledHandler checks if the task is cancelled // Priority: 1 (highest - checked first) type CancelledHandler struct { BaseHandler } func (h *CancelledHandler) Handle(ctx *Context) KanbanColumn { if ctx.Task.IsCancelled { return ColumnCancelled } return h.HandleNext(ctx) } // CompletedHandler checks if the task is completed (one-time task with completions and no next due date) // Priority: 2 type CompletedHandler struct { BaseHandler } func (h *CompletedHandler) Handle(ctx *Context) KanbanColumn { // A task is completed if: // - It has at least one completion record // - AND it has no NextDueDate (meaning it's a one-time task or the cycle is done) if ctx.Task.NextDueDate == nil && len(ctx.Task.Completions) > 0 { return ColumnCompleted } return h.HandleNext(ctx) } // InProgressHandler checks if the task status is "In Progress" // Priority: 3 type InProgressHandler struct { BaseHandler } func (h *InProgressHandler) Handle(ctx *Context) KanbanColumn { if ctx.Task.Status != nil && ctx.Task.Status.Name == "In Progress" { return ColumnInProgress } return h.HandleNext(ctx) } // OverdueHandler checks if the task is overdue based on NextDueDate or DueDate // Priority: 4 type OverdueHandler struct { BaseHandler } func (h *OverdueHandler) Handle(ctx *Context) KanbanColumn { effectiveDate := h.getEffectiveDate(ctx.Task) if effectiveDate != nil && effectiveDate.Before(ctx.Now) { return ColumnOverdue } return h.HandleNext(ctx) } func (h *OverdueHandler) getEffectiveDate(task *models.Task) *time.Time { // Prefer NextDueDate for recurring tasks if task.NextDueDate != nil { return task.NextDueDate } // Fall back to DueDate for initial categorization return task.DueDate } // DueSoonHandler checks if the task is due within the threshold period // Priority: 5 type DueSoonHandler struct { BaseHandler } func (h *DueSoonHandler) Handle(ctx *Context) KanbanColumn { effectiveDate := h.getEffectiveDate(ctx.Task) threshold := ctx.ThresholdDate() if effectiveDate != nil && effectiveDate.Before(threshold) { return ColumnDueSoon } return h.HandleNext(ctx) } func (h *DueSoonHandler) getEffectiveDate(task *models.Task) *time.Time { if task.NextDueDate != nil { return task.NextDueDate } return task.DueDate } // UpcomingHandler is the final handler that catches all remaining tasks // Priority: 6 (lowest - default) type UpcomingHandler struct { BaseHandler } func (h *UpcomingHandler) Handle(ctx *Context) KanbanColumn { // This is the default catch-all return ColumnUpcoming } // === Chain Builder === // Chain manages the categorization chain type Chain struct { head Handler } // NewChain creates a new categorization chain with handlers in priority order func NewChain() *Chain { // Build the chain in priority order (first handler has highest priority) cancelled := &CancelledHandler{} completed := &CompletedHandler{} inProgress := &InProgressHandler{} overdue := &OverdueHandler{} dueSoon := &DueSoonHandler{} upcoming := &UpcomingHandler{} // Chain them together: cancelled -> completed -> inProgress -> overdue -> dueSoon -> upcoming cancelled.SetNext(completed). SetNext(inProgress). SetNext(overdue). SetNext(dueSoon). SetNext(upcoming) return &Chain{head: cancelled} } // Categorize determines which kanban column a task belongs to func (c *Chain) Categorize(task *models.Task, daysThreshold int) KanbanColumn { ctx := NewContext(task, daysThreshold) return c.head.Handle(ctx) } // CategorizeWithContext uses a pre-built context for categorization func (c *Chain) CategorizeWithContext(ctx *Context) KanbanColumn { return c.head.Handle(ctx) } // === Convenience Functions === // defaultChain is a singleton chain instance for convenience var defaultChain = NewChain() // DetermineKanbanColumn is a convenience function that uses the default chain func DetermineKanbanColumn(task *models.Task, daysThreshold int) string { return defaultChain.Categorize(task, daysThreshold).String() } // CategorizeTask is an alias for DetermineKanbanColumn with a more descriptive name func CategorizeTask(task *models.Task, daysThreshold int) KanbanColumn { return defaultChain.Categorize(task, daysThreshold) } // CategorizeTasksIntoColumns categorizes multiple tasks into their respective columns func CategorizeTasksIntoColumns(tasks []models.Task, daysThreshold int) map[KanbanColumn][]models.Task { result := make(map[KanbanColumn][]models.Task) // Initialize all columns with empty slices for _, col := range []KanbanColumn{ ColumnOverdue, ColumnDueSoon, ColumnUpcoming, ColumnInProgress, ColumnCompleted, ColumnCancelled, } { result[col] = make([]models.Task, 0) } // Categorize each task chain := NewChain() for _, task := range tasks { column := chain.Categorize(&task, daysThreshold) result[column] = append(result[column], task) } return result }