// 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/honeydue-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 && HasCompletions(task) } // 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 before today. // // A task is overdue when: // - It has an effective date (NextDueDate or DueDate) // - That date is before the start of the current day // - The task is not completed, cancelled, or archived // // Note: A task due "today" is NOT overdue. It becomes overdue tomorrow. // // SQL equivalent (in scopes.go ScopeOverdue): // // COALESCE(next_due_date, due_date) < DATE_TRUNC('day', ?) // 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 } // Compare against start of today, not current time // A task due "today" should not be overdue until tomorrow startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) return effectiveDate.Before(startOfDay) } // 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 >= start of today AND < start of (today + daysThreshold) // - The task is not completed, cancelled, archived, or already overdue // // Note: Uses start of day for comparisons so tasks due "today" are included. // // SQL equivalent (in scopes.go ScopeDueSoon): // // COALESCE(next_due_date, due_date) >= DATE_TRUNC('day', ?) // AND COALESCE(next_due_date, due_date) < DATE_TRUNC('day', ?) + interval 'N days' // 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 } // Use start of day for comparisons startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) threshold := startOfDay.AddDate(0, 0, daysThreshold) // Due soon = not overdue (>= start of today) AND before threshold return !effectiveDate.Before(startOfDay) && 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 >= start of (today + daysThreshold) // - The task is not completed, cancelled, or archived // // Note: Uses start of day for comparisons for consistency with other predicates. // // 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 } // Use start of day for comparisons startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) threshold := startOfDay.AddDate(0, 0, daysThreshold) return !effectiveDate.Before(threshold) } // ============================================================================= // COMPLETION HELPERS // ============================================================================= // HasCompletions returns true if the task has at least one completion record. // Supports both preloaded Completions slice and computed CompletionCount field // for optimized queries that use COUNT subqueries instead of preloading. func HasCompletions(task *models.Task) bool { // If Completions were preloaded, use the slice if len(task.Completions) > 0 { return true } // Otherwise check the computed count (populated via subquery for optimized queries) return task.CompletionCount > 0 } // GetCompletionCount returns the number of completions for a task. // Supports both preloaded Completions slice and computed CompletionCount field // for optimized queries that use COUNT subqueries instead of preloading. func GetCompletionCount(task *models.Task) int { // If Completions were preloaded, use the slice length if len(task.Completions) > 0 { return len(task.Completions) } // Otherwise return the computed count (populated via subquery for optimized queries) return task.CompletionCount } // ============================================================================= // 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) }