// 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. // // IMPORTANT: This package uses predicates from the parent task package as the // single source of truth for task logic. Do NOT duplicate logic here. package categorization import ( "time" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/task/predicates" ) // 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(t *models.Task, daysThreshold int) *Context { if daysThreshold <= 0 { daysThreshold = 30 } return &Context{ Task: t, 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 === // Each handler uses predicates from the task package as the source of truth. // CancelledHandler checks if the task is cancelled // Priority: 1 (highest - checked first) type CancelledHandler struct { BaseHandler } func (h *CancelledHandler) Handle(ctx *Context) KanbanColumn { // Uses predicate: predicates.IsCancelled if predicates.IsCancelled(ctx.Task) { 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 { // Uses predicate: predicates.IsCompleted // A task is completed if NextDueDate is nil AND has at least one completion if predicates.IsCompleted(ctx.Task) { 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 { // Uses predicate: predicates.IsInProgress if predicates.IsInProgress(ctx.Task) { 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 { // Uses predicate: predicates.EffectiveDate // Note: We don't use predicates.IsOverdue here because the chain has already // filtered out cancelled and completed tasks. We just need the date check. effectiveDate := predicates.EffectiveDate(ctx.Task) if effectiveDate != nil && effectiveDate.Before(ctx.Now) { return ColumnOverdue } return h.HandleNext(ctx) } // DueSoonHandler checks if the task is due within the threshold period // Priority: 5 type DueSoonHandler struct { BaseHandler } func (h *DueSoonHandler) Handle(ctx *Context) KanbanColumn { // Uses predicate: predicates.EffectiveDate effectiveDate := predicates.EffectiveDate(ctx.Task) threshold := ctx.ThresholdDate() if effectiveDate != nil && effectiveDate.Before(threshold) { return ColumnDueSoon } return h.HandleNext(ctx) } // 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 for tasks that: // - Are not cancelled, completed, or in progress // - Are not overdue or due soon // - Have a due date far in the future OR no due date at 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(t *models.Task, daysThreshold int) KanbanColumn { ctx := NewContext(t, 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(t *models.Task, daysThreshold int) string { return defaultChain.Categorize(t, daysThreshold).String() } // CategorizeTask is an alias for DetermineKanbanColumn with a more descriptive name func CategorizeTask(t *models.Task, daysThreshold int) KanbanColumn { return defaultChain.Categorize(t, 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 _, t := range tasks { column := chain.Categorize(&t, daysThreshold) result[column] = append(result[column], t) } return result }