// Package predicates provides pure predicate functions for task logic. // These functions are the SINGLE SOURCE OF TRUTH for all task-related business logic. // // IMPORTANT: The scopes in ../scopes/scopes.go must mirror these predicates exactly. // Any change to predicate logic MUST be reflected in the corresponding scope. // Tests verify consistency between predicates and scopes. package predicates import ( "time" "github.com/treytartt/casera-api/internal/models" ) // ============================================================================= // STATE PREDICATES // ============================================================================= // IsCompleted returns true if a task is considered "completed" per kanban rules. // // A task is completed when: // - NextDueDate is nil (no future occurrence scheduled) // - AND it has at least one completion record // // This applies to one-time tasks. Recurring tasks always have a NextDueDate // after completion, so they never enter the "completed" state permanently. // // SQL equivalent (in scopes.go ScopeCompleted): // // next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id) func IsCompleted(task *models.Task) bool { return task.NextDueDate == nil && len(task.Completions) > 0 } // IsActive returns true if the task is not cancelled and not archived. // Active tasks are eligible for display in the kanban board. // // SQL equivalent (in scopes.go ScopeActive): // // is_cancelled = false AND is_archived = false func IsActive(task *models.Task) bool { return !task.IsCancelled && !task.IsArchived } // IsCancelled returns true if the task has been cancelled. // // SQL equivalent: // // is_cancelled = true func IsCancelled(task *models.Task) bool { return task.IsCancelled } // IsArchived returns true if the task has been archived. // // SQL equivalent: // // is_archived = true func IsArchived(task *models.Task) bool { return task.IsArchived } // IsInProgress returns true if the task is marked as in progress. // // SQL equivalent (in scopes.go ScopeInProgress): // // in_progress = true func IsInProgress(task *models.Task) bool { return task.InProgress } // ============================================================================= // DATE PREDICATES // ============================================================================= // EffectiveDate returns the date used for scheduling calculations. // // For recurring tasks that have been completed at least once, NextDueDate // contains the next occurrence. For new tasks or one-time tasks, we fall // back to DueDate. // // Returns nil if task has no due date set. // // SQL equivalent: // // COALESCE(next_due_date, due_date) func EffectiveDate(task *models.Task) *time.Time { if task.NextDueDate != nil { return task.NextDueDate } return task.DueDate } // IsOverdue returns true if the task's effective date is in the past. // // A task is overdue when: // - It has an effective date (NextDueDate or DueDate) // - That date is before the given time // - The task is not completed, cancelled, or archived // // SQL equivalent (in scopes.go ScopeOverdue): // // COALESCE(next_due_date, due_date) < ? // AND NOT (next_due_date IS NULL AND EXISTS completion) // AND is_cancelled = false AND is_archived = false func IsOverdue(task *models.Task, now time.Time) bool { if !IsActive(task) || IsCompleted(task) { return false } effectiveDate := EffectiveDate(task) if effectiveDate == nil { return false } return effectiveDate.Before(now) } // IsDueSoon returns true if the task's effective date is within the threshold. // // A task is "due soon" when: // - It has an effective date (NextDueDate or DueDate) // - That date is >= now AND < (now + daysThreshold) // - The task is not completed, cancelled, archived, or already overdue // // SQL equivalent (in scopes.go ScopeDueSoon): // // COALESCE(next_due_date, due_date) >= ? // AND COALESCE(next_due_date, due_date) < ? // AND NOT (next_due_date IS NULL AND EXISTS completion) // AND is_cancelled = false AND is_archived = false func IsDueSoon(task *models.Task, now time.Time, daysThreshold int) bool { if !IsActive(task) || IsCompleted(task) { return false } effectiveDate := EffectiveDate(task) if effectiveDate == nil { return false } threshold := now.AddDate(0, 0, daysThreshold) // Due soon = not overdue AND before threshold return !effectiveDate.Before(now) && effectiveDate.Before(threshold) } // IsUpcoming returns true if the task is due after the threshold or has no due date. // // A task is "upcoming" when: // - It has no effective date, OR // - Its effective date is >= (now + daysThreshold) // - The task is not completed, cancelled, or archived // // This is the default category for tasks that don't match other criteria. func IsUpcoming(task *models.Task, now time.Time, daysThreshold int) bool { if !IsActive(task) || IsCompleted(task) { return false } effectiveDate := EffectiveDate(task) if effectiveDate == nil { return true // No due date = upcoming } threshold := now.AddDate(0, 0, daysThreshold) return !effectiveDate.Before(threshold) } // ============================================================================= // COMPLETION HELPERS // ============================================================================= // HasCompletions returns true if the task has at least one completion record. func HasCompletions(task *models.Task) bool { return len(task.Completions) > 0 } // CompletionCount returns the number of completions for a task. func CompletionCount(task *models.Task) int { return len(task.Completions) } // ============================================================================= // RECURRING TASK HELPERS // ============================================================================= // IsRecurring returns true if the task has a recurring frequency. func IsRecurring(task *models.Task) bool { return task.Frequency != nil && task.Frequency.Days != nil && *task.Frequency.Days > 0 } // IsOneTime returns true if the task is a one-time (non-recurring) task. func IsOneTime(task *models.Task) bool { return !IsRecurring(task) }