// Package scopes provides GORM scope functions that mirror the predicates. // These scopes allow efficient database-level filtering using the same logic // as the predicates in ../predicates/predicates.go. // // IMPORTANT: These scopes must produce the same results as their predicate counterparts. // Any change to a predicate MUST be reflected in the corresponding scope. // Tests verify consistency between predicates and scopes. // // Each scope includes a comment referencing its predicate counterpart for easy cross-reference. package scopes import ( "time" "gorm.io/gorm" ) // ============================================================================= // STATE SCOPES // ============================================================================= // ScopeActive filters to tasks that are not cancelled and not archived. // Active tasks are eligible for display in the kanban board. // // Predicate equivalent: IsActive(task) // // SQL: is_cancelled = false AND is_archived = false func ScopeActive(db *gorm.DB) *gorm.DB { return db.Where("is_cancelled = ? AND is_archived = ?", false, false) } // ScopeCancelled filters to cancelled tasks only. // // Predicate equivalent: IsCancelled(task) // // SQL: is_cancelled = true func ScopeCancelled(db *gorm.DB) *gorm.DB { return db.Where("is_cancelled = ?", true) } // ScopeArchived filters to archived tasks only. // // Predicate equivalent: IsArchived(task) // // SQL: is_archived = true func ScopeArchived(db *gorm.DB) *gorm.DB { return db.Where("is_archived = ?", true) } // ScopeCompleted filters to completed tasks. // // A task is completed when NextDueDate is nil AND it has at least one completion. // // Predicate equivalent: IsCompleted(task) // // SQL: next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id) func ScopeCompleted(db *gorm.DB) *gorm.DB { return db.Where( "next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)", ) } // ScopeNotCompleted excludes completed tasks. // // A task is NOT completed when it either has a NextDueDate OR has no completions. // // Predicate equivalent: !IsCompleted(task) // // SQL: NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)) func ScopeNotCompleted(db *gorm.DB) *gorm.DB { return db.Where( "NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))", ) } // ScopeInProgress filters to tasks marked as in progress. // // Predicate equivalent: IsInProgress(task) // // SQL: in_progress = true func ScopeInProgress(db *gorm.DB) *gorm.DB { return db.Where("in_progress = ?", true) } // ScopeNotInProgress excludes tasks marked as in progress. // // Predicate equivalent: !IsInProgress(task) // // SQL: in_progress = false func ScopeNotInProgress(db *gorm.DB) *gorm.DB { return db.Where("in_progress = ?", false) } // ============================================================================= // DATE SCOPES // ============================================================================= // ScopeOverdue returns a scope for overdue tasks. // // A task is overdue when its effective date (COALESCE(next_due_date, due_date)) // is before the start of the given day, and it's active and not completed. // // Note: A task due "today" is NOT overdue. It becomes overdue tomorrow. // // Predicate equivalent: IsOverdue(task, now) // // IMPORTANT: Due dates are stored as midnight UTC but represent calendar dates. // We compare dates (YYYY-MM-DD), not timestamps, to ensure timezone correctness. // A task due "2024-12-16" should only be overdue when the user's date is "2024-12-17" or later. // // Uses DATE() function which works in both PostgreSQL and SQLite. func ScopeOverdue(now time.Time) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { // Extract user's current date as a string for date-only comparison // This ensures timezone correctness: the user's "today" determines overdue status todayStr := now.Format("2006-01-02") return db.Scopes(ScopeActive, ScopeNotCompleted). Where("DATE(COALESCE(next_due_date, due_date)) < DATE(?)", todayStr) } } // ScopeDueSoon returns a scope for tasks due within the threshold. // // A task is "due soon" when its effective date is >= start of today AND < start of (today + threshold), // and it's active and not completed. // // Note: Uses day-level comparisons so tasks due "today" are included. // // Predicate equivalent: IsDueSoon(task, now, daysThreshold) // // IMPORTANT: Uses date comparison (not timestamps) for timezone correctness. func ScopeDueSoon(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { // Extract dates as strings for date-only comparison todayStr := now.Format("2006-01-02") thresholdDate := now.AddDate(0, 0, daysThreshold) thresholdStr := thresholdDate.Format("2006-01-02") return db.Scopes(ScopeActive, ScopeNotCompleted). Where("DATE(COALESCE(next_due_date, due_date)) >= DATE(?)", todayStr). Where("DATE(COALESCE(next_due_date, due_date)) < DATE(?)", thresholdStr) } } // ScopeUpcoming returns a scope for tasks due after the threshold or with no due date. // // A task is "upcoming" when its effective date is >= start of (today + threshold) OR is null, // and it's active and not completed. // // Predicate equivalent: IsUpcoming(task, now, daysThreshold) // // IMPORTANT: Uses date comparison (not timestamps) for timezone correctness. func ScopeUpcoming(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { // Compute threshold date thresholdDate := now.AddDate(0, 0, daysThreshold) thresholdStr := thresholdDate.Format("2006-01-02") return db.Scopes(ScopeActive, ScopeNotCompleted). Where( "DATE(COALESCE(next_due_date, due_date)) >= DATE(?) OR (next_due_date IS NULL AND due_date IS NULL)", thresholdStr, ) } } // ScopeDueInRange returns a scope for tasks with effective date in a range. // // IMPORTANT: Uses date comparison (not timestamps) for timezone correctness. func ScopeDueInRange(start, end time.Time) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { startStr := start.Format("2006-01-02") endStr := end.Format("2006-01-02") return db. Where("DATE(COALESCE(next_due_date, due_date)) >= DATE(?)", startStr). Where("DATE(COALESCE(next_due_date, due_date)) < DATE(?)", endStr) } } // ScopeHasDueDate filters to tasks that have an effective due date. // // SQL: (next_due_date IS NOT NULL OR due_date IS NOT NULL) func ScopeHasDueDate(db *gorm.DB) *gorm.DB { return db.Where("next_due_date IS NOT NULL OR due_date IS NOT NULL") } // ScopeNoDueDate filters to tasks that have no effective due date. // // SQL: next_due_date IS NULL AND due_date IS NULL func ScopeNoDueDate(db *gorm.DB) *gorm.DB { return db.Where("next_due_date IS NULL AND due_date IS NULL") } // ============================================================================= // FILTER SCOPES // ============================================================================= // ScopeForResidence filters tasks by a single residence ID. // // SQL: residence_id = ? func ScopeForResidence(residenceID uint) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { return db.Where("residence_id = ?", residenceID) } } // ScopeForResidences filters tasks by multiple residence IDs. // // SQL: residence_id IN (?) func ScopeForResidences(residenceIDs []uint) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { if len(residenceIDs) == 0 { // Return empty result if no residence IDs provided return db.Where("1 = 0") } return db.Where("residence_id IN ?", residenceIDs) } } // ScopeHasCompletions filters to tasks that have at least one completion. // // Predicate equivalent: HasCompletions(task) // // SQL: EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id) func ScopeHasCompletions(db *gorm.DB) *gorm.DB { return db.Where( "EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)", ) } // ScopeNoCompletions filters to tasks that have no completions. // // Predicate equivalent: !HasCompletions(task) // // SQL: NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id) func ScopeNoCompletions(db *gorm.DB) *gorm.DB { return db.Where( "NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)", ) } // ============================================================================= // ORDERING // ============================================================================= // ScopeOrderByDueDate orders tasks by effective due date ascending, nulls last. // // SQL: ORDER BY COALESCE(next_due_date, due_date) ASC NULLS LAST func ScopeOrderByDueDate(db *gorm.DB) *gorm.DB { return db.Order("COALESCE(next_due_date, due_date) ASC NULLS LAST") } // ScopeOrderByPriority orders tasks by priority level descending (urgent first). // // SQL: ORDER BY priority_id DESC func ScopeOrderByPriority(db *gorm.DB) *gorm.DB { return db.Order("priority_id DESC") } // ScopeOrderByCreatedAt orders tasks by creation date descending (newest first). // // SQL: ORDER BY created_at DESC func ScopeOrderByCreatedAt(db *gorm.DB) *gorm.DB { return db.Order("created_at DESC") } // ScopeKanbanOrder applies the standard kanban ordering. // // SQL: ORDER BY COALESCE(next_due_date, due_date) ASC NULLS LAST, priority_id DESC, created_at DESC func ScopeKanbanOrder(db *gorm.DB) *gorm.DB { return db.Order("COALESCE(next_due_date, due_date) ASC NULLS LAST, priority_id DESC, created_at DESC") }