diff --git a/docs/TASK_LOGIC_ARCHITECTURE.md b/docs/TASK_LOGIC_ARCHITECTURE.md new file mode 100644 index 0000000..35a0f1a --- /dev/null +++ b/docs/TASK_LOGIC_ARCHITECTURE.md @@ -0,0 +1,369 @@ +# Task Logic Architecture + +This document describes the consolidated task logic architecture following DRY principles. All task-related logic (completion detection, overdue calculation, kanban categorization, statistics counting) is centralized in the `internal/task/` package. + +## Architecture Overview + +``` +internal/task/ +├── predicates/ +│ └── predicates.go # SINGLE SOURCE OF TRUTH - Pure Go predicate functions +├── scopes/ +│ └── scopes.go # SQL mirrors of predicates for GORM queries +├── categorization/ +│ └── chain.go # Chain of Responsibility for kanban column determination +└── task.go # Re-exports for convenient single import +``` + +## Package Responsibilities + +### predicates/ - Single Source of Truth + +Pure Go functions that define task logic. These are the **canonical definitions** for all task states. + +```go +import "github.com/treytartt/casera-api/internal/task/predicates" + +// State checks +predicates.IsCompleted(task) // NextDueDate == nil && len(Completions) > 0 +predicates.IsActive(task) // !IsCancelled && !IsArchived +predicates.IsCancelled(task) // IsCancelled == true +predicates.IsArchived(task) // IsArchived == true +predicates.IsInProgress(task) // Status.Name == "In Progress" + +// Date calculations +predicates.EffectiveDate(task) // NextDueDate ?? DueDate +predicates.IsOverdue(task, now) // Active, not completed, effectiveDate < now +predicates.IsDueSoon(task, now, days) // Active, not completed, now <= effectiveDate < threshold +predicates.IsUpcoming(task, now, days) // Everything else +``` + +### scopes/ - SQL Mirrors for Database Queries + +GORM scope functions that produce the same results as predicates, but execute at the database level. Use these when counting or filtering large datasets without loading all records into memory. + +```go +import "github.com/treytartt/casera-api/internal/task/scopes" + +// State scopes +db.Scopes(scopes.ScopeActive) // is_cancelled = false AND is_archived = false +db.Scopes(scopes.ScopeCompleted) // next_due_date IS NULL AND EXISTS(completion) +db.Scopes(scopes.ScopeNotCompleted) // NOT (next_due_date IS NULL AND EXISTS(completion)) +db.Scopes(scopes.ScopeInProgress) // JOIN status WHERE name = 'In Progress' + +// Date scopes (require time parameter) +db.Scopes(scopes.ScopeOverdue(now)) // COALESCE(next_due_date, due_date) < now +db.Scopes(scopes.ScopeDueSoon(now, 30)) // >= now AND < threshold +db.Scopes(scopes.ScopeUpcoming(now, 30)) // >= threshold OR no due date + +// Filter scopes +db.Scopes(scopes.ScopeForResidence(id)) // residence_id = ? +db.Scopes(scopes.ScopeForResidences(ids)) // residence_id IN (?) + +// Ordering +db.Scopes(scopes.ScopeKanbanOrder) // Due date ASC, priority DESC, created DESC +``` + +### categorization/ - Chain of Responsibility Pattern + +Determines which kanban column a task belongs to. Uses predicates internally. + +```go +import "github.com/treytartt/casera-api/internal/task/categorization" + +// Single task +column := categorization.CategorizeTask(task, 30) +columnStr := categorization.DetermineKanbanColumn(task, 30) + +// Multiple tasks +columns := categorization.CategorizeTasksIntoColumns(tasks, 30) +// Returns map[KanbanColumn][]Task +``` + +### task.go - Convenient Re-exports + +For most use cases, import the main task package which re-exports everything: + +```go +import "github.com/treytartt/casera-api/internal/task" + +// Use predicates +if task.IsCompleted(t) { ... } + +// Use scopes +db.Scopes(task.ScopeOverdue(now)).Count(&count) + +// Use categorization +column := task.CategorizeTask(t, 30) +``` + +## Canonical Rules (Single Source of Truth) + +These rules are defined in `predicates/predicates.go` and enforced everywhere: + +| Concept | Definition | SQL Equivalent | +|---------|------------|----------------| +| **Completed** | `NextDueDate == nil && len(Completions) > 0` | `next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion WHERE task_id = ?)` | +| **Active** | `!IsCancelled && !IsArchived` | `is_cancelled = false AND is_archived = false` | +| **In Progress** | `Status.Name == "In Progress"` | `JOIN task_taskstatus WHERE name = 'In Progress'` | +| **Effective Date** | `NextDueDate ?? DueDate` | `COALESCE(next_due_date, due_date)` | +| **Overdue** | `Active && !Completed && EffectiveDate < now` | Active + NotCompleted + `COALESCE(...) < ?` | +| **Due Soon** | `Active && !Completed && now <= EffectiveDate < threshold` | Active + NotCompleted + `COALESCE(...) >= ? AND COALESCE(...) < ?` | +| **Upcoming** | Everything else (future or no due date) | Active + NotCompleted + `COALESCE(...) >= ? OR (both NULL)` | + +## Kanban Column Priority Order + +When categorizing a task, the chain evaluates in this priority order: + +1. **Cancelled** (highest) - `IsCancelled == true` +2. **Completed** - `NextDueDate == nil && len(Completions) > 0` +3. **In Progress** - `Status.Name == "In Progress"` +4. **Overdue** - `EffectiveDate < now` +5. **Due Soon** - `now <= EffectiveDate < threshold` +6. **Upcoming** (lowest/default) - Everything else + +## Usage Examples + +### Counting Overdue Tasks (Efficient) + +```go +// Use scopes for database-level counting +var count int64 +db.Model(&models.Task{}). + Scopes(task.ScopeForResidences(residenceIDs), task.ScopeOverdue(now)). + Count(&count) +``` + +### Building a Kanban Board + +```go +// Load tasks with preloads +var tasks []models.Task +db.Preload("Status").Preload("Completions"). + Scopes(task.ScopeForResidence(residenceID)). + Find(&tasks) + +// Categorize in memory using predicates +columns := task.CategorizeTasksIntoColumns(tasks, 30) +``` + +### Checking Task State in Business Logic + +```go +// Use predicates for in-memory checks +if task.IsCompleted(t) { + return []string{} // No actions for completed tasks +} + +if task.IsOverdue(t, time.Now().UTC()) { + return []string{"edit", "complete", "cancel", "mark_in_progress"} +} +``` + +### Button Types for a Task + +```go +func GetButtonTypesForTask(t *models.Task, daysThreshold int) []string { + now := time.Now().UTC() + + if predicates.IsCancelled(t) { + return []string{"uncancel", "delete"} + } + if predicates.IsCompleted(t) { + return []string{} // read-only + } + if predicates.IsInProgress(t) { + return []string{"edit", "complete", "cancel"} + } + // Overdue, Due Soon, Upcoming all get the same buttons + return []string{"edit", "complete", "cancel", "mark_in_progress"} +} +``` + +## Why Two Layers (Predicates + Scopes)? + +| Layer | Use Case | Performance | +|-------|----------|-------------| +| **Predicates** | In-memory checks on loaded objects | Fast for small sets, already loaded data | +| **Scopes** | Database-level filtering/counting | Efficient for large datasets, avoids loading all records | + +**Example**: Counting overdue tasks across 1000 residences: +- Predicates: Load all tasks into memory, filter in Go +- Scopes: Execute `SELECT COUNT(*) WHERE ...` in database + +## Consistency Guarantees + +1. **Scopes mirror predicates** - Each scope produces the same logical result as its predicate counterpart +2. **Tests verify consistency** - Unit tests ensure predicates and scopes produce identical results +3. **Cross-reference comments** - Each scope has a comment linking to its predicate equivalent +4. **Single import point** - The `task` package re-exports everything, making it easy to use consistently + +## Files That Were Refactored + +The following files were updated to use the consolidated task logic: + +| File | Changes | +|------|---------| +| `internal/repositories/task_repo.go` | Removed duplicate `isTaskCompleted()`, uses categorization and scopes | +| `internal/services/task_button_types.go` | Uses predicates instead of inline logic | +| `internal/dto/responses/task.go` | `DetermineKanbanColumn` delegates to categorization package | +| `internal/worker/jobs/handler.go` | SQL queries documented with predicate references | +| `internal/admin/handlers/dashboard_handler.go` | Uses scopes for task statistics | +| `internal/services/residence_service.go` | Uses predicates for report generation | +| `internal/models/task.go` | `IsOverdue()` and `IsDueSoon()` fixed to use EffectiveDate | + +## Migration Notes + +### Old Pattern (Avoid) + +```go +// DON'T: Inline logic that may drift +if task.NextDueDate == nil && len(task.Completions) > 0 { + // completed... +} +``` + +### New Pattern (Preferred) + +```go +// DO: Use predicates +if predicates.IsCompleted(task) { + // completed... +} + +// Or via the task package +if task.IsCompleted(t) { + // completed... +} +``` + +## Developer Checklist: Adding Task-Related Features + +**BEFORE writing any task-related code, ask yourself:** + +### 1. Does this logic already exist? + +Check `internal/task/predicates/predicates.go` for: +- [ ] State checks: `IsCompleted`, `IsActive`, `IsCancelled`, `IsArchived`, `IsInProgress` +- [ ] Date logic: `EffectiveDate`, `IsOverdue`, `IsDueSoon`, `IsUpcoming` +- [ ] Completion helpers: `HasCompletions`, `CompletionCount`, `IsRecurring` + +### 2. Am I querying tasks from the database? + +Use scopes from `internal/task/scopes/scopes.go`: +- [ ] Filtering by state: `ScopeActive`, `ScopeCompleted`, `ScopeNotCompleted` +- [ ] Filtering by date: `ScopeOverdue(now)`, `ScopeDueSoon(now, days)`, `ScopeUpcoming(now, days)` +- [ ] Filtering by residence: `ScopeForResidence(id)`, `ScopeForResidences(ids)` +- [ ] Ordering: `ScopeKanbanOrder` + +### 3. Am I categorizing tasks into kanban columns? + +Use `internal/task/categorization`: +- [ ] Single task: `categorization.CategorizeTask(task, daysThreshold)` +- [ ] Multiple tasks: `categorization.CategorizeTasksIntoColumns(tasks, daysThreshold)` + +### 4. Am I writing inline task logic? + +**STOP!** If you're writing any of these patterns inline, use the existing functions instead: + +```go +// BAD: Inline completion check +if task.NextDueDate == nil && len(task.Completions) > 0 { ... } + +// GOOD: Use predicate +if predicates.IsCompleted(task) { ... } + +// BAD: Inline SQL for overdue +db.Where("due_date < ?", now) + +// GOOD: Use scope +db.Scopes(scopes.ScopeOverdue(now)) + +// BAD: Manual kanban categorization +if task.IsCancelled { return "cancelled" } +else if ... { return "completed" } + +// GOOD: Use categorization chain +column := categorization.CategorizeTask(task, 30) +``` + +### 5. Am I adding a NEW task state or concept? + +If the existing predicates don't cover your use case: +1. **Add the predicate first** in `predicates/predicates.go` +2. **Add the corresponding scope** in `scopes/scopes.go` (if DB queries needed) +3. **Update the categorization chain** if it affects kanban columns +4. **Add tests** for both predicate and scope +5. **Update this documentation** + +## Common Pitfalls + +### PostgreSQL DATE vs TIMESTAMP Comparison + +When comparing DATE columns with time parameters, GORM passes `time.Time` as a string. PostgreSQL then compares dates, not timestamps: + +```sql +-- This compares DATES (wrong for time-of-day precision): +'2025-12-07'::date < '2025-12-07 17:00:00' -- FALSE! + +-- This compares TIMESTAMPS (correct): +'2025-12-07'::date < '2025-12-07 17:00:00'::timestamp -- TRUE +``` + +**Solution**: All date scopes use explicit `::timestamp` casts: +```go +Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", now) +``` + +### Forgetting to Preload Completions + +The `IsCompleted` predicate checks `len(task.Completions) > 0`. If you query tasks without preloading completions, this will always return false: + +```go +// BAD: Completions not loaded +db.Find(&tasks) +predicates.IsCompleted(task) // Always false! + +// GOOD: Preload completions +db.Preload("Completions").Find(&tasks) +predicates.IsCompleted(task) // Correct result +``` + +### Forgetting to Preload Status + +The `IsInProgress` predicate checks `task.Status.Name == "In Progress"`. Without preloading: + +```go +// BAD: Status not loaded +db.Find(&tasks) +predicates.IsInProgress(task) // Nil pointer or always false + +// GOOD: Preload status +db.Preload("Status").Find(&tasks) +predicates.IsInProgress(task) // Correct result +``` + +## Quick Reference Import + +For most files, use the convenience re-exports: + +```go +import "github.com/treytartt/casera-api/internal/task" + +// Then use: +task.IsCompleted(t) +task.ScopeOverdue(now) +task.CategorizeTask(t, 30) +``` + +For files that only need predicates or only need scopes: + +```go +import "github.com/treytartt/casera-api/internal/task/predicates" +import "github.com/treytartt/casera-api/internal/task/scopes" +``` + +## Related Documentation + +- `docs/TASK_KANBAN_CATEGORIZATION.md` - Detailed kanban column logic +- `docs/TASK_KANBAN_LOGIC.md` - Original kanban implementation notes diff --git a/internal/admin/handlers/dashboard_handler.go b/internal/admin/handlers/dashboard_handler.go index 4d89633..c1c00b2 100644 --- a/internal/admin/handlers/dashboard_handler.go +++ b/internal/admin/handlers/dashboard_handler.go @@ -8,6 +8,7 @@ import ( "gorm.io/gorm" "github.com/treytartt/casera-api/internal/models" + "github.com/treytartt/casera-api/internal/task/scopes" ) // AdminDashboardHandler handles admin dashboard endpoints @@ -115,43 +116,41 @@ func (h *AdminDashboardHandler) GetStats(c *gin.Context) { h.db.Model(&models.Residence{}).Where("is_active = ?", true).Count(&stats.Residences.Active) h.db.Model(&models.Residence{}).Where("created_at >= ?", thirtyDaysAgo).Count(&stats.Residences.New30d) - // Task stats + // Task stats - uses scopes from internal/task/scopes for consistency h.db.Model(&models.Task{}).Count(&stats.Tasks.Total) - h.db.Model(&models.Task{}).Where("is_cancelled = ? AND is_archived = ?", false, false).Count(&stats.Tasks.Active) - h.db.Model(&models.Task{}).Where("is_archived = ?", true).Count(&stats.Tasks.Archived) - h.db.Model(&models.Task{}).Where("is_cancelled = ?", true).Count(&stats.Tasks.Cancelled) + h.db.Model(&models.Task{}).Scopes(scopes.ScopeActive).Count(&stats.Tasks.Active) + h.db.Model(&models.Task{}).Scopes(scopes.ScopeArchived).Count(&stats.Tasks.Archived) + h.db.Model(&models.Task{}).Scopes(scopes.ScopeCancelled).Count(&stats.Tasks.Cancelled) h.db.Model(&models.Task{}).Where("created_at >= ?", thirtyDaysAgo).Count(&stats.Tasks.New30d) // Task counts by status (using LEFT JOIN to handle tasks with no status) + // Note: These status counts use DB status names, not kanban categorization h.db.Model(&models.Task{}). - Where("is_cancelled = ? AND is_archived = ?", false, false). + Scopes(scopes.ScopeActive). Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id"). Where("LOWER(task_taskstatus.name) = ? OR task_taskstatus.id IS NULL", "pending"). Count(&stats.Tasks.Pending) h.db.Model(&models.Task{}). - Where("is_cancelled = ? AND is_archived = ?", false, false). - Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id"). - Where("LOWER(task_taskstatus.name) = ?", "in progress"). + Scopes(scopes.ScopeActive, scopes.ScopeInProgress). Count(&stats.Tasks.InProgress) + // Completed count: uses kanban completion logic (NextDueDate == nil AND has completions) + // See internal/task/predicates.IsCompleted for the definition h.db.Model(&models.Task{}). - Where("is_cancelled = ? AND is_archived = ?", false, false). - Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id"). - Where("LOWER(task_taskstatus.name) = ?", "completed"). + Scopes(scopes.ScopeActive, scopes.ScopeCompleted). Count(&stats.Tasks.Completed) h.db.Model(&models.Task{}). - Where("is_cancelled = ? AND is_archived = ?", false, false). + Scopes(scopes.ScopeActive). Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id"). Where("LOWER(task_taskstatus.name) = ?", "on hold"). Count(&stats.Tasks.OnHold) - // Overdue: past due date, not completed, not cancelled, not archived + // Overdue: uses consistent logic from internal/task/scopes.ScopeOverdue + // Effective date (COALESCE(next_due_date, due_date)) < now, active, not completed h.db.Model(&models.Task{}). - Where("next_due_date < ? AND is_cancelled = ? AND is_archived = ?", now, false, false). - Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id"). - Where("LOWER(task_taskstatus.name) NOT IN ? OR task_taskstatus.id IS NULL", []string{"completed", "cancelled"}). + Scopes(scopes.ScopeOverdue(now)). Count(&stats.Tasks.Overdue) // Contractor stats diff --git a/internal/dto/responses/task.go b/internal/dto/responses/task.go index 3a57a4c..961c9a2 100644 --- a/internal/dto/responses/task.go +++ b/internal/dto/responses/task.go @@ -7,6 +7,7 @@ import ( "github.com/shopspring/decimal" "github.com/treytartt/casera-api/internal/models" + "github.com/treytartt/casera-api/internal/task/categorization" ) // TaskCategoryResponse represents a task category @@ -365,53 +366,8 @@ func NewTaskCompletionWithTaskResponse(c *models.TaskCompletion, task *models.Ta } // DetermineKanbanColumn determines which kanban column a task belongs to. -// This is a wrapper around the Chain of Responsibility implementation in -// internal/task/categorization package. See that package for detailed -// documentation on the categorization logic. -// -// Deprecated: Use categorization.DetermineKanbanColumn directly for new code. +// Delegates to internal/task/categorization package which is the single source +// of truth for task categorization logic. func DetermineKanbanColumn(task *models.Task, daysThreshold int) string { - // Import would cause circular dependency, so we replicate the logic here - // for backwards compatibility. The authoritative implementation is in - // internal/task/categorization/chain.go - if daysThreshold <= 0 { - daysThreshold = 30 // Default - } - - now := time.Now().UTC() - threshold := now.AddDate(0, 0, daysThreshold) - - // Priority order (Chain of Responsibility): - // 1. Cancelled (highest priority) - if task.IsCancelled { - return "cancelled_tasks" - } - - // 2. Completed (one-time task with nil next_due_date and has completions) - if task.NextDueDate == nil && len(task.Completions) > 0 { - return "completed_tasks" - } - - // 3. In Progress (status check) - if task.Status != nil && task.Status.Name == "In Progress" { - return "in_progress_tasks" - } - - // 4. Overdue (next_due_date or due_date is in the past) - effectiveDate := task.NextDueDate - if effectiveDate == nil { - effectiveDate = task.DueDate - } - if effectiveDate != nil { - if effectiveDate.Before(now) { - return "overdue_tasks" - } - // 5. Due Soon (within threshold) - if effectiveDate.Before(threshold) { - return "due_soon_tasks" - } - } - - // 6. Upcoming (default/fallback) - return "upcoming_tasks" + return categorization.DetermineKanbanColumn(task, daysThreshold) } diff --git a/internal/models/task.go b/internal/models/task.go index e972ed5..7983f95 100644 --- a/internal/models/task.go +++ b/internal/models/task.go @@ -110,28 +110,66 @@ func (Task) TableName() string { return "task_task" } -// IsOverdue returns true if the task is past its due date and not completed +// IsOverdue returns true if the task is past its due date and not completed. +// +// IMPORTANT: This method delegates to the predicates package which is the +// single source of truth for task logic. It uses EffectiveDate (NextDueDate ?? DueDate) +// rather than just DueDate, ensuring consistency with kanban categorization. +// +// Deprecated: Prefer using task.IsOverdue(t, time.Now().UTC()) directly for explicit time control. func (t *Task) IsOverdue() bool { - if t.DueDate == nil || t.IsCancelled || t.IsArchived { + // Delegate to predicates package - single source of truth + // Import is avoided here to prevent circular dependency. + // Logic must match predicates.IsOverdue exactly: + // - Check active (not cancelled, not archived) + // - Check not completed (NextDueDate != nil || no completions) + // - Check effective date < now + if t.IsCancelled || t.IsArchived { return false } - // Check if there's a completion - if len(t.Completions) > 0 { + // Completed check: NextDueDate == nil AND has completions + if t.NextDueDate == nil && len(t.Completions) > 0 { return false } - return time.Now().UTC().After(*t.DueDate) + // Effective date: NextDueDate ?? DueDate + effectiveDate := t.NextDueDate + if effectiveDate == nil { + effectiveDate = t.DueDate + } + if effectiveDate == nil { + return false + } + return effectiveDate.Before(time.Now().UTC()) } -// IsDueSoon returns true if the task is due within the specified days +// IsDueSoon returns true if the task is due within the specified days. +// +// IMPORTANT: This method uses EffectiveDate (NextDueDate ?? DueDate) +// rather than just DueDate, ensuring consistency with kanban categorization. +// +// Deprecated: Prefer using task.IsDueSoon(t, time.Now().UTC(), days) directly for explicit time control. func (t *Task) IsDueSoon(days int) bool { - if t.DueDate == nil || t.IsCancelled || t.IsArchived { + // Delegate to predicates package logic - single source of truth + // Logic must match predicates.IsDueSoon exactly + if t.IsCancelled || t.IsArchived { return false } - if len(t.Completions) > 0 { + // Completed check: NextDueDate == nil AND has completions + if t.NextDueDate == nil && len(t.Completions) > 0 { return false } - threshold := time.Now().UTC().AddDate(0, 0, days) - return t.DueDate.Before(threshold) && !t.IsOverdue() + // Effective date: NextDueDate ?? DueDate + effectiveDate := t.NextDueDate + if effectiveDate == nil { + effectiveDate = t.DueDate + } + if effectiveDate == nil { + return false + } + now := time.Now().UTC() + threshold := now.AddDate(0, 0, days) + // Due soon = not overdue AND before threshold + return !effectiveDate.Before(now) && effectiveDate.Before(threshold) } // TaskCompletion represents the task_taskcompletion table diff --git a/internal/repositories/task_repo.go b/internal/repositories/task_repo.go index 9773174..2f61a92 100644 --- a/internal/repositories/task_repo.go +++ b/internal/repositories/task_repo.go @@ -6,16 +6,10 @@ import ( "gorm.io/gorm" "github.com/treytartt/casera-api/internal/models" + "github.com/treytartt/casera-api/internal/task" + "github.com/treytartt/casera-api/internal/task/categorization" ) -// isTaskCompleted determines if a task should be considered "completed" for kanban display. -// A task is completed if next_due_date is nil (meaning it was a one-time task that's been completed). -// Recurring tasks always have a next_due_date after completion, so they're never "completed" permanently. -func isTaskCompleted(task *models.Task) bool { - // If next_due_date is nil and task has completions, it's a completed one-time task - return task.NextDueDate == nil && len(task.Completions) > 0 -} - // TaskRepository handles database operations for tasks type TaskRepository struct { db *gorm.DB @@ -141,6 +135,7 @@ func (r *TaskRepository) Unarchive(id uint) error { // === Kanban Board === // GetKanbanData retrieves tasks organized for kanban display +// Uses the task.categorization package as the single source of truth for categorization logic. func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*models.KanbanBoard, error) { var tasks []models.Task err := r.db.Preload("CreatedBy"). @@ -153,120 +148,69 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo Preload("Completions.Images"). Preload("Completions.CompletedBy"). Where("residence_id = ? AND is_archived = ?", residenceID, false). - Order("due_date ASC NULLS LAST, priority_id DESC, created_at DESC"). + Scopes(task.ScopeKanbanOrder). Find(&tasks).Error if err != nil { return nil, err } - // Organize into columns - now := time.Now().UTC() - threshold := now.AddDate(0, 0, daysThreshold) - - overdue := make([]models.Task, 0) - dueSoon := make([]models.Task, 0) - upcoming := make([]models.Task, 0) - inProgress := make([]models.Task, 0) - completed := make([]models.Task, 0) - cancelled := make([]models.Task, 0) - - for _, task := range tasks { - if task.IsCancelled { - cancelled = append(cancelled, task) - continue - } - - // Check if completed (one-time task with nil next_due_date) - if isTaskCompleted(&task) { - completed = append(completed, task) - continue - } - - // Check status for in-progress (status_id = 2 typically) - if task.Status != nil && task.Status.Name == "In Progress" { - inProgress = append(inProgress, task) - continue - } - - // Use next_due_date for categorization (this handles recurring tasks properly) - if task.NextDueDate != nil { - if task.NextDueDate.Before(now) { - overdue = append(overdue, task) - } else if task.NextDueDate.Before(threshold) { - dueSoon = append(dueSoon, task) - } else { - upcoming = append(upcoming, task) - } - } else { - // No next_due_date and no completions - use due_date for initial categorization - if task.DueDate != nil { - if task.DueDate.Before(now) { - overdue = append(overdue, task) - } else if task.DueDate.Before(threshold) { - dueSoon = append(dueSoon, task) - } else { - upcoming = append(upcoming, task) - } - } else { - upcoming = append(upcoming, task) - } - } - } + // Use the categorization package as the single source of truth + categorized := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold) columns := []models.KanbanColumn{ { - Name: "overdue_tasks", + Name: string(categorization.ColumnOverdue), DisplayName: "Overdue", ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"}, Color: "#FF3B30", - Tasks: overdue, - Count: len(overdue), + Tasks: categorized[categorization.ColumnOverdue], + Count: len(categorized[categorization.ColumnOverdue]), }, { - Name: "in_progress_tasks", + Name: string(categorization.ColumnInProgress), DisplayName: "In Progress", ButtonTypes: []string{"edit", "complete", "cancel"}, Icons: map[string]string{"ios": "hammer", "android": "Build"}, Color: "#5856D6", - Tasks: inProgress, - Count: len(inProgress), + Tasks: categorized[categorization.ColumnInProgress], + Count: len(categorized[categorization.ColumnInProgress]), }, { - Name: "due_soon_tasks", + Name: string(categorization.ColumnDueSoon), DisplayName: "Due Soon", ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "clock", "android": "Schedule"}, Color: "#FF9500", - Tasks: dueSoon, - Count: len(dueSoon), + Tasks: categorized[categorization.ColumnDueSoon], + Count: len(categorized[categorization.ColumnDueSoon]), }, { - Name: "upcoming_tasks", + Name: string(categorization.ColumnUpcoming), DisplayName: "Upcoming", ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "calendar", "android": "Event"}, Color: "#007AFF", - Tasks: upcoming, - Count: len(upcoming), + Tasks: categorized[categorization.ColumnUpcoming], + Count: len(categorized[categorization.ColumnUpcoming]), }, { - Name: "completed_tasks", + Name: string(categorization.ColumnCompleted), DisplayName: "Completed", ButtonTypes: []string{}, Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"}, Color: "#34C759", - Tasks: completed, - Count: len(completed), + Tasks: categorized[categorization.ColumnCompleted], + Count: len(categorized[categorization.ColumnCompleted]), }, { - Name: "cancelled_tasks", + Name: string(categorization.ColumnCancelled), DisplayName: "Cancelled", ButtonTypes: []string{"uncancel", "delete"}, Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"}, Color: "#8E8E93", - Tasks: cancelled, - Count: len(cancelled), + Tasks: categorized[categorization.ColumnCancelled], + Count: len(categorized[categorization.ColumnCancelled]), }, } @@ -278,6 +222,7 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo } // GetKanbanDataForMultipleResidences retrieves tasks from multiple residences organized for kanban display +// Uses the task.categorization package as the single source of truth for categorization logic. func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, daysThreshold int) (*models.KanbanBoard, error) { var tasks []models.Task err := r.db.Preload("CreatedBy"). @@ -291,120 +236,69 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, Preload("Completions.CompletedBy"). Preload("Residence"). Where("residence_id IN ? AND is_archived = ?", residenceIDs, false). - Order("due_date ASC NULLS LAST, priority_id DESC, created_at DESC"). + Scopes(task.ScopeKanbanOrder). Find(&tasks).Error if err != nil { return nil, err } - // Organize into columns - now := time.Now().UTC() - threshold := now.AddDate(0, 0, daysThreshold) - - overdue := make([]models.Task, 0) - dueSoon := make([]models.Task, 0) - upcoming := make([]models.Task, 0) - inProgress := make([]models.Task, 0) - completed := make([]models.Task, 0) - cancelled := make([]models.Task, 0) - - for _, task := range tasks { - if task.IsCancelled { - cancelled = append(cancelled, task) - continue - } - - // Check if completed (one-time task with nil next_due_date) - if isTaskCompleted(&task) { - completed = append(completed, task) - continue - } - - // Check status for in-progress - if task.Status != nil && task.Status.Name == "In Progress" { - inProgress = append(inProgress, task) - continue - } - - // Use next_due_date for categorization (this handles recurring tasks properly) - if task.NextDueDate != nil { - if task.NextDueDate.Before(now) { - overdue = append(overdue, task) - } else if task.NextDueDate.Before(threshold) { - dueSoon = append(dueSoon, task) - } else { - upcoming = append(upcoming, task) - } - } else { - // No next_due_date and no completions - use due_date for initial categorization - if task.DueDate != nil { - if task.DueDate.Before(now) { - overdue = append(overdue, task) - } else if task.DueDate.Before(threshold) { - dueSoon = append(dueSoon, task) - } else { - upcoming = append(upcoming, task) - } - } else { - upcoming = append(upcoming, task) - } - } - } + // Use the categorization package as the single source of truth + categorized := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold) columns := []models.KanbanColumn{ { - Name: "overdue_tasks", + Name: string(categorization.ColumnOverdue), DisplayName: "Overdue", ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"}, Color: "#FF3B30", - Tasks: overdue, - Count: len(overdue), + Tasks: categorized[categorization.ColumnOverdue], + Count: len(categorized[categorization.ColumnOverdue]), }, { - Name: "in_progress_tasks", + Name: string(categorization.ColumnInProgress), DisplayName: "In Progress", ButtonTypes: []string{"edit", "complete", "cancel"}, Icons: map[string]string{"ios": "hammer", "android": "Build"}, Color: "#5856D6", - Tasks: inProgress, - Count: len(inProgress), + Tasks: categorized[categorization.ColumnInProgress], + Count: len(categorized[categorization.ColumnInProgress]), }, { - Name: "due_soon_tasks", + Name: string(categorization.ColumnDueSoon), DisplayName: "Due Soon", ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "clock", "android": "Schedule"}, Color: "#FF9500", - Tasks: dueSoon, - Count: len(dueSoon), + Tasks: categorized[categorization.ColumnDueSoon], + Count: len(categorized[categorization.ColumnDueSoon]), }, { - Name: "upcoming_tasks", + Name: string(categorization.ColumnUpcoming), DisplayName: "Upcoming", ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "calendar", "android": "Event"}, Color: "#007AFF", - Tasks: upcoming, - Count: len(upcoming), + Tasks: categorized[categorization.ColumnUpcoming], + Count: len(categorized[categorization.ColumnUpcoming]), }, { - Name: "completed_tasks", + Name: string(categorization.ColumnCompleted), DisplayName: "Completed", ButtonTypes: []string{}, Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"}, Color: "#34C759", - Tasks: completed, - Count: len(completed), + Tasks: categorized[categorization.ColumnCompleted], + Count: len(categorized[categorization.ColumnCompleted]), }, { - Name: "cancelled_tasks", + Name: string(categorization.ColumnCancelled), DisplayName: "Cancelled", ButtonTypes: []string{"uncancel", "delete"}, Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"}, Color: "#8E8E93", - Tasks: cancelled, - Count: len(cancelled), + Tasks: categorized[categorization.ColumnCancelled], + Count: len(categorized[categorization.ColumnCancelled]), }, } @@ -544,62 +438,57 @@ type TaskStatistics struct { TasksDueNextMonth int } -// GetTaskStatistics returns aggregated task statistics for multiple residences +// GetTaskStatistics returns aggregated task statistics for multiple residences. +// Uses the task.scopes package for consistent filtering logic. func (r *TaskRepository) GetTaskStatistics(residenceIDs []uint) (*TaskStatistics, error) { if len(residenceIDs) == 0 { return &TaskStatistics{}, nil } now := time.Now().UTC() - nextWeek := now.AddDate(0, 0, 7) - nextMonth := now.AddDate(0, 1, 0) var totalTasks, totalOverdue, totalPending, tasksDueNextWeek, tasksDueNextMonth int64 // Count total active tasks (not cancelled, not archived) + // Uses: task.ScopeActive, task.ScopeForResidences err := r.db.Model(&models.Task{}). - Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false). + Scopes(task.ScopeForResidences(residenceIDs), task.ScopeActive). Count(&totalTasks).Error if err != nil { return nil, err } - // Count overdue tasks: due_date or next_due_date < now, and NOT completed - // A task is "completed" if next_due_date IS NULL AND has at least one completion + // Count overdue tasks using consistent scope + // Uses: task.ScopeOverdue (which includes ScopeActive and ScopeNotCompleted) err = r.db.Model(&models.Task{}). - Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false). - Where("(due_date < ? OR next_due_date < ?)", now, now). - // Exclude completed tasks: tasks with no next_due_date AND at least one completion - Where("NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))"). + Scopes(task.ScopeForResidences(residenceIDs), task.ScopeOverdue(now)). Count(&totalOverdue).Error if err != nil { return nil, err } - // Count pending tasks (not completed, not cancelled, not archived) + // Count pending tasks (active, not completed) + // Uses: task.ScopeActive, task.ScopeNotCompleted err = r.db.Model(&models.Task{}). - Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false). - Where("id NOT IN (?)", r.db.Table("task_taskcompletion").Select("task_id")). + Scopes(task.ScopeForResidences(residenceIDs), task.ScopeActive, task.ScopeNotCompleted). Count(&totalPending).Error if err != nil { return nil, err } - // Count tasks due next week (due date between now and 7 days, not completed) + // Count tasks due next week using consistent scope + // Uses: task.ScopeDueSoon with 7-day threshold err = r.db.Model(&models.Task{}). - Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false). - Where("due_date >= ? AND due_date < ?", now, nextWeek). - Where("id NOT IN (?)", r.db.Table("task_taskcompletion").Select("task_id")). + Scopes(task.ScopeForResidences(residenceIDs), task.ScopeDueSoon(now, 7)). Count(&tasksDueNextWeek).Error if err != nil { return nil, err } - // Count tasks due next month (due date between now and 30 days, not completed) + // Count tasks due next month using consistent scope + // Uses: task.ScopeDueSoon with 30-day threshold err = r.db.Model(&models.Task{}). - Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false). - Where("due_date >= ? AND due_date < ?", now, nextMonth). - Where("id NOT IN (?)", r.db.Table("task_taskcompletion").Select("task_id")). + Scopes(task.ScopeForResidences(residenceIDs), task.ScopeDueSoon(now, 30)). Count(&tasksDueNextMonth).Error if err != nil { return nil, err diff --git a/internal/services/residence_service.go b/internal/services/residence_service.go index 7227995..1cd382a 100644 --- a/internal/services/residence_service.go +++ b/internal/services/residence_service.go @@ -11,6 +11,7 @@ import ( "github.com/treytartt/casera-api/internal/dto/responses" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/repositories" + "github.com/treytartt/casera-api/internal/task/predicates" ) // Common errors @@ -553,8 +554,9 @@ func (s *ResidenceService) GenerateTasksReport(residenceID, userID uint) (*Tasks } for i, task := range tasks { - // Determine if task is completed (has completions) - isCompleted := len(task.Completions) > 0 + // Use predicates from internal/task/predicates as single source of truth + isCompleted := predicates.IsCompleted(&task) + isOverdue := predicates.IsOverdue(&task, now) taskData := TaskReportData{ ID: task.ID, @@ -574,17 +576,19 @@ func (s *ResidenceService) GenerateTasksReport(residenceID, userID uint) (*Tasks if task.Status != nil { taskData.Status = task.Status.Name } - if task.DueDate != nil { - taskData.DueDate = task.DueDate + // Use effective date for report (NextDueDate ?? DueDate) + effectiveDate := predicates.EffectiveDate(&task) + if effectiveDate != nil { + taskData.DueDate = effectiveDate } report.Tasks[i] = taskData if isCompleted { report.Completed++ - } else if !task.IsCancelled && !task.IsArchived { + } else if predicates.IsActive(&task) { report.Pending++ - if task.DueDate != nil && task.DueDate.Before(now) { + if isOverdue { report.Overdue++ } } diff --git a/internal/services/task_button_types.go b/internal/services/task_button_types.go index ba12b8f..e2005c4 100644 --- a/internal/services/task_button_types.go +++ b/internal/services/task_button_types.go @@ -4,6 +4,7 @@ import ( "time" "github.com/treytartt/casera-api/internal/models" + "github.com/treytartt/casera-api/internal/task/predicates" ) // iOS Notification Category Identifiers @@ -15,65 +16,52 @@ const ( IOSCategoryTaskGeneric = "TASK_NOTIFICATION_GENERIC" // non-premium users ) -// GetButtonTypesForTask returns the appropriate button_types for a task -// This reuses the same categorization logic as GetKanbanData in task_repo.go +// GetButtonTypesForTask returns the appropriate button_types for a task. +// Uses predicates from internal/task/predicates as the single source of truth. +// Priority order matches kanban categorization chain. func GetButtonTypesForTask(task *models.Task, daysThreshold int) []string { now := time.Now().UTC() - threshold := now.AddDate(0, 0, daysThreshold) - // Priority order matches kanban logic - if task.IsCancelled { + // Priority order matches kanban logic (see categorization/chain.go) + // 1. Cancelled + if predicates.IsCancelled(task) { return []string{"uncancel", "delete"} } - // Check if task is "completed" (one-time task with nil next_due_date) - if isTaskCompleted(task) { + // 2. Completed (one-time task with nil next_due_date and has completions) + if predicates.IsCompleted(task) { return []string{} // read-only } - if task.Status != nil && task.Status.Name == "In Progress" { + // 3. In Progress + if predicates.IsInProgress(task) { return []string{"edit", "complete", "cancel"} } - // Use next_due_date for categorization (handles recurring tasks properly) - if task.NextDueDate != nil { - if task.NextDueDate.Before(now) { - // Overdue - return []string{"edit", "complete", "cancel", "mark_in_progress"} - } else if task.NextDueDate.Before(threshold) { - // Due Soon - return []string{"edit", "complete", "cancel", "mark_in_progress"} - } - } else if task.DueDate != nil { - // Fallback to due_date if next_due_date not set yet - if task.DueDate.Before(now) { - return []string{"edit", "complete", "cancel", "mark_in_progress"} - } else if task.DueDate.Before(threshold) { - return []string{"edit", "complete", "cancel", "mark_in_progress"} - } + // 4. Overdue + if predicates.IsOverdue(task, now) { + return []string{"edit", "complete", "cancel", "mark_in_progress"} } - // Upcoming (default for tasks with future due dates or no due date) + // 5. Due Soon + if predicates.IsDueSoon(task, now, daysThreshold) { + return []string{"edit", "complete", "cancel", "mark_in_progress"} + } + + // 6. Upcoming (default for tasks with future due dates or no due date) return []string{"edit", "complete", "cancel", "mark_in_progress"} } -// isTaskCompleted determines if a task should be considered "completed" for kanban display. -// A task is completed if next_due_date is nil (meaning it was a one-time task that's been completed). -// Recurring tasks always have a next_due_date after completion, so they're never "completed" permanently. -func isTaskCompleted(task *models.Task) bool { - // If next_due_date is nil and task has completions, it's a completed one-time task - return task.NextDueDate == nil && len(task.Completions) > 0 -} - -// GetIOSCategoryForTask returns the iOS notification category identifier +// GetIOSCategoryForTask returns the iOS notification category identifier. +// Uses predicates from internal/task/predicates as the single source of truth. func GetIOSCategoryForTask(task *models.Task) string { - if task.IsCancelled { + if predicates.IsCancelled(task) { return IOSCategoryTaskCancelled } - if isTaskCompleted(task) { + if predicates.IsCompleted(task) { return IOSCategoryTaskCompleted } - if task.Status != nil && task.Status.Name == "In Progress" { + if predicates.IsInProgress(task) { return IOSCategoryTaskInProgress } return IOSCategoryTaskActionable diff --git a/internal/task/categorization/chain.go b/internal/task/categorization/chain.go index 788853f..bca5c60 100644 --- a/internal/task/categorization/chain.go +++ b/internal/task/categorization/chain.go @@ -4,12 +4,16 @@ // 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 @@ -37,12 +41,12 @@ type Context struct { } // NewContext creates a new categorization context with sensible defaults -func NewContext(task *models.Task, daysThreshold int) *Context { +func NewContext(t *models.Task, daysThreshold int) *Context { if daysThreshold <= 0 { daysThreshold = 30 } return &Context{ - Task: task, + Task: t, Now: time.Now().UTC(), DaysThreshold: daysThreshold, } @@ -83,6 +87,7 @@ func (h *BaseHandler) HandleNext(ctx *Context) KanbanColumn { } // === 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) @@ -91,7 +96,8 @@ type CancelledHandler struct { } func (h *CancelledHandler) Handle(ctx *Context) KanbanColumn { - if ctx.Task.IsCancelled { + // Uses predicate: predicates.IsCancelled + if predicates.IsCancelled(ctx.Task) { return ColumnCancelled } return h.HandleNext(ctx) @@ -104,10 +110,9 @@ type CompletedHandler struct { } 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 { + // 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) @@ -120,7 +125,8 @@ type InProgressHandler struct { } func (h *InProgressHandler) Handle(ctx *Context) KanbanColumn { - if ctx.Task.Status != nil && ctx.Task.Status.Name == "In Progress" { + // Uses predicate: predicates.IsInProgress + if predicates.IsInProgress(ctx.Task) { return ColumnInProgress } return h.HandleNext(ctx) @@ -133,22 +139,16 @@ type OverdueHandler struct { } func (h *OverdueHandler) Handle(ctx *Context) KanbanColumn { - effectiveDate := h.getEffectiveDate(ctx.Task) + // 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) } -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 { @@ -156,7 +156,8 @@ type DueSoonHandler struct { } func (h *DueSoonHandler) Handle(ctx *Context) KanbanColumn { - effectiveDate := h.getEffectiveDate(ctx.Task) + // Uses predicate: predicates.EffectiveDate + effectiveDate := predicates.EffectiveDate(ctx.Task) threshold := ctx.ThresholdDate() if effectiveDate != nil && effectiveDate.Before(threshold) { @@ -165,13 +166,6 @@ func (h *DueSoonHandler) Handle(ctx *Context) KanbanColumn { 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 { @@ -179,7 +173,10 @@ type UpcomingHandler struct { } func (h *UpcomingHandler) Handle(ctx *Context) KanbanColumn { - // This is the default catch-all + // 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 } @@ -211,8 +208,8 @@ func NewChain() *Chain { } // Categorize determines which kanban column a task belongs to -func (c *Chain) Categorize(task *models.Task, daysThreshold int) KanbanColumn { - ctx := NewContext(task, daysThreshold) +func (c *Chain) Categorize(t *models.Task, daysThreshold int) KanbanColumn { + ctx := NewContext(t, daysThreshold) return c.head.Handle(ctx) } @@ -227,13 +224,13 @@ func (c *Chain) CategorizeWithContext(ctx *Context) KanbanColumn { 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() +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(task *models.Task, daysThreshold int) KanbanColumn { - return defaultChain.Categorize(task, daysThreshold) +func CategorizeTask(t *models.Task, daysThreshold int) KanbanColumn { + return defaultChain.Categorize(t, daysThreshold) } // CategorizeTasksIntoColumns categorizes multiple tasks into their respective columns @@ -250,9 +247,9 @@ func CategorizeTasksIntoColumns(tasks []models.Task, daysThreshold int) map[Kanb // Categorize each task chain := NewChain() - for _, task := range tasks { - column := chain.Categorize(&task, daysThreshold) - result[column] = append(result[column], task) + for _, t := range tasks { + column := chain.Categorize(&t, daysThreshold) + result[column] = append(result[column], t) } return result diff --git a/internal/task/categorization/chain_test.go b/internal/task/categorization/chain_test.go index 9b662ff..2aaa5b6 100644 --- a/internal/task/categorization/chain_test.go +++ b/internal/task/categorization/chain_test.go @@ -1,12 +1,11 @@ -package categorization +package categorization_test import ( "testing" "time" - "github.com/stretchr/testify/assert" - "github.com/treytartt/casera-api/internal/models" + "github.com/treytartt/casera-api/internal/task/categorization" ) // Helper to create a time pointer @@ -14,362 +13,224 @@ func timePtr(t time.Time) *time.Time { return &t } -// Helper to create a uint pointer -func uintPtr(v uint) *uint { - return &v -} +func TestCategorizeTask_PriorityOrder(t *testing.T) { + now := time.Now().UTC() + yesterday := now.AddDate(0, 0, -1) + in5Days := now.AddDate(0, 0, 5) + in60Days := now.AddDate(0, 0, 60) + inProgressStatus := &models.TaskStatus{Name: "In Progress"} + daysThreshold := 30 -// Helper to create a completion with an ID -func makeCompletion(id uint) models.TaskCompletion { - c := models.TaskCompletion{CompletedAt: time.Now()} - c.ID = id - return c -} + tests := []struct { + name string + task *models.Task + expected categorization.KanbanColumn + }{ + // Priority 1: Cancelled + { + name: "cancelled takes priority over everything", + task: &models.Task{ + IsCancelled: true, + NextDueDate: timePtr(yesterday), // Would be overdue + Status: inProgressStatus, // Would be in progress + Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, // Would be completed if NextDueDate was nil + }, + expected: categorization.ColumnCancelled, + }, -// Helper to create a task with an ID -func makeTask(id uint) models.Task { - t := models.Task{} - t.ID = id - return t -} + // Priority 2: Completed + { + name: "completed: NextDueDate nil with completions", + task: &models.Task{ + IsCancelled: false, + IsArchived: false, + NextDueDate: nil, + DueDate: timePtr(yesterday), // Would be overdue if not completed + Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, + }, + expected: categorization.ColumnCompleted, + }, + { + name: "not completed when NextDueDate set (recurring task with completions)", + task: &models.Task{ + IsCancelled: false, + IsArchived: false, + NextDueDate: timePtr(in5Days), + Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, + }, + expected: categorization.ColumnDueSoon, // Falls through to due soon + }, -func TestCancelledHandler(t *testing.T) { - chain := NewChain() + // Priority 3: In Progress + { + name: "in progress takes priority over overdue", + task: &models.Task{ + IsCancelled: false, + IsArchived: false, + NextDueDate: timePtr(yesterday), // Would be overdue + Status: inProgressStatus, + Completions: []models.TaskCompletion{}, + }, + expected: categorization.ColumnInProgress, + }, - t.Run("cancelled task goes to cancelled column", func(t *testing.T) { - task := &models.Task{ - IsCancelled: true, - } - result := chain.Categorize(task, 30) - assert.Equal(t, ColumnCancelled, result) - }) + // Priority 4: Overdue + { + name: "overdue: effective date in past", + task: &models.Task{ + IsCancelled: false, + IsArchived: false, + NextDueDate: timePtr(yesterday), + Completions: []models.TaskCompletion{}, + }, + expected: categorization.ColumnOverdue, + }, + { + name: "overdue: uses DueDate when NextDueDate nil (no completions)", + task: &models.Task{ + IsCancelled: false, + IsArchived: false, + NextDueDate: nil, + DueDate: timePtr(yesterday), + Completions: []models.TaskCompletion{}, + }, + expected: categorization.ColumnOverdue, + }, - t.Run("cancelled task with due date still goes to cancelled", func(t *testing.T) { - dueDate := time.Now().AddDate(0, 0, -10) // 10 days ago (overdue) - task := &models.Task{ - IsCancelled: true, - DueDate: &dueDate, - } - result := chain.Categorize(task, 30) - assert.Equal(t, ColumnCancelled, result) - }) -} + // Priority 5: Due Soon + { + name: "due soon: within threshold", + task: &models.Task{ + IsCancelled: false, + IsArchived: false, + NextDueDate: timePtr(in5Days), + Completions: []models.TaskCompletion{}, + }, + expected: categorization.ColumnDueSoon, + }, -func TestCompletedHandler(t *testing.T) { - chain := NewChain() + // Priority 6: Upcoming (default) + { + name: "upcoming: beyond threshold", + task: &models.Task{ + IsCancelled: false, + IsArchived: false, + NextDueDate: timePtr(in60Days), + Completions: []models.TaskCompletion{}, + }, + expected: categorization.ColumnUpcoming, + }, + { + name: "upcoming: no due date", + task: &models.Task{ + IsCancelled: false, + IsArchived: false, + NextDueDate: nil, + DueDate: nil, + Completions: []models.TaskCompletion{}, + }, + expected: categorization.ColumnUpcoming, + }, + } - t.Run("one-time task with completion and no next_due_date goes to completed", func(t *testing.T) { - task := &models.Task{ - NextDueDate: nil, - Completions: []models.TaskCompletion{makeCompletion(1)}, - } - result := chain.Categorize(task, 30) - assert.Equal(t, ColumnCompleted, result) - }) - - t.Run("recurring task with completion but has next_due_date does NOT go to completed", func(t *testing.T) { - nextDue := time.Now().AddDate(0, 0, 30) - task := &models.Task{ - NextDueDate: &nextDue, - Completions: []models.TaskCompletion{makeCompletion(1)}, - } - result := chain.Categorize(task, 30) - // Should go to due_soon or upcoming, not completed - assert.NotEqual(t, ColumnCompleted, result) - }) - - t.Run("task with no completions does not go to completed", func(t *testing.T) { - task := &models.Task{ - NextDueDate: nil, - Completions: []models.TaskCompletion{}, - } - result := chain.Categorize(task, 30) - assert.NotEqual(t, ColumnCompleted, result) - }) -} - -func TestInProgressHandler(t *testing.T) { - chain := NewChain() - - t.Run("task with In Progress status goes to in_progress column", func(t *testing.T) { - task := &models.Task{ - Status: &models.TaskStatus{Name: "In Progress"}, - } - result := chain.Categorize(task, 30) - assert.Equal(t, ColumnInProgress, result) - }) - - t.Run("task with Pending status does not go to in_progress", func(t *testing.T) { - task := &models.Task{ - Status: &models.TaskStatus{Name: "Pending"}, - } - result := chain.Categorize(task, 30) - assert.NotEqual(t, ColumnInProgress, result) - }) - - t.Run("task with nil status does not go to in_progress", func(t *testing.T) { - task := &models.Task{ - Status: nil, - } - result := chain.Categorize(task, 30) - assert.NotEqual(t, ColumnInProgress, result) - }) -} - -func TestOverdueHandler(t *testing.T) { - chain := NewChain() - - t.Run("task with past next_due_date goes to overdue", func(t *testing.T) { - pastDate := time.Now().AddDate(0, 0, -5) // 5 days ago - task := &models.Task{ - NextDueDate: &pastDate, - Status: &models.TaskStatus{Name: "Pending"}, - } - result := chain.Categorize(task, 30) - assert.Equal(t, ColumnOverdue, result) - }) - - t.Run("task with past due_date (no next_due_date) goes to overdue", func(t *testing.T) { - pastDate := time.Now().AddDate(0, 0, -5) // 5 days ago - task := &models.Task{ - DueDate: &pastDate, - NextDueDate: nil, - Status: &models.TaskStatus{Name: "Pending"}, - } - result := chain.Categorize(task, 30) - assert.Equal(t, ColumnOverdue, result) - }) - - t.Run("next_due_date takes precedence over due_date", func(t *testing.T) { - pastDueDate := time.Now().AddDate(0, 0, -10) // 10 days ago - futureNextDue := time.Now().AddDate(0, 0, 60) // 60 days from now - task := &models.Task{ - DueDate: &pastDueDate, - NextDueDate: &futureNextDue, - Status: &models.TaskStatus{Name: "Pending"}, - } - result := chain.Categorize(task, 30) - // Should be upcoming (60 days > 30 day threshold), not overdue - assert.Equal(t, ColumnUpcoming, result) - }) -} - -func TestDueSoonHandler(t *testing.T) { - chain := NewChain() - - t.Run("task due within threshold goes to due_soon", func(t *testing.T) { - dueDate := time.Now().AddDate(0, 0, 15) // 15 days from now - task := &models.Task{ - NextDueDate: &dueDate, - Status: &models.TaskStatus{Name: "Pending"}, - } - result := chain.Categorize(task, 30) // 30 day threshold - assert.Equal(t, ColumnDueSoon, result) - }) - - t.Run("task due exactly at threshold goes to due_soon", func(t *testing.T) { - dueDate := time.Now().AddDate(0, 0, 29) // Just under 30 days - task := &models.Task{ - NextDueDate: &dueDate, - Status: &models.TaskStatus{Name: "Pending"}, - } - result := chain.Categorize(task, 30) - assert.Equal(t, ColumnDueSoon, result) - }) - - t.Run("custom threshold is respected", func(t *testing.T) { - dueDate := time.Now().AddDate(0, 0, 10) // 10 days from now - task := &models.Task{ - NextDueDate: &dueDate, - Status: &models.TaskStatus{Name: "Pending"}, - } - // With 7 day threshold, 10 days out should be upcoming, not due_soon - result := chain.Categorize(task, 7) - assert.Equal(t, ColumnUpcoming, result) - }) -} - -func TestUpcomingHandler(t *testing.T) { - chain := NewChain() - - t.Run("task with future next_due_date beyond threshold goes to upcoming", func(t *testing.T) { - futureDate := time.Now().AddDate(0, 0, 60) // 60 days from now - task := &models.Task{ - NextDueDate: &futureDate, - Status: &models.TaskStatus{Name: "Pending"}, - } - result := chain.Categorize(task, 30) - assert.Equal(t, ColumnUpcoming, result) - }) - - t.Run("task with no due date goes to upcoming (default)", func(t *testing.T) { - task := &models.Task{ - DueDate: nil, - NextDueDate: nil, - Status: &models.TaskStatus{Name: "Pending"}, - } - result := chain.Categorize(task, 30) - assert.Equal(t, ColumnUpcoming, result) - }) -} - -func TestChainPriorityOrder(t *testing.T) { - chain := NewChain() - - t.Run("cancelled takes priority over everything", func(t *testing.T) { - pastDate := time.Now().AddDate(0, 0, -10) - task := &models.Task{ - IsCancelled: true, - DueDate: &pastDate, - NextDueDate: nil, - Completions: []models.TaskCompletion{makeCompletion(1)}, - Status: &models.TaskStatus{Name: "In Progress"}, - } - result := chain.Categorize(task, 30) - assert.Equal(t, ColumnCancelled, result) - }) - - t.Run("completed takes priority over in_progress", func(t *testing.T) { - task := &models.Task{ - IsCancelled: false, - NextDueDate: nil, - Completions: []models.TaskCompletion{makeCompletion(1)}, - Status: &models.TaskStatus{Name: "In Progress"}, - } - result := chain.Categorize(task, 30) - assert.Equal(t, ColumnCompleted, result) - }) - - t.Run("in_progress takes priority over overdue", func(t *testing.T) { - pastDate := time.Now().AddDate(0, 0, -10) - task := &models.Task{ - IsCancelled: false, - NextDueDate: &pastDate, - Status: &models.TaskStatus{Name: "In Progress"}, - } - result := chain.Categorize(task, 30) - assert.Equal(t, ColumnInProgress, result) - }) - - t.Run("overdue takes priority over due_soon", func(t *testing.T) { - pastDate := time.Now().AddDate(0, 0, -1) - task := &models.Task{ - IsCancelled: false, - NextDueDate: &pastDate, - Status: &models.TaskStatus{Name: "Pending"}, - } - result := chain.Categorize(task, 30) - assert.Equal(t, ColumnOverdue, result) - }) -} - -func TestRecurringTaskScenarios(t *testing.T) { - chain := NewChain() - - t.Run("annual task just completed should go to upcoming (next_due_date is 1 year out)", func(t *testing.T) { - nextYear := time.Now().AddDate(1, 0, 0) - task := &models.Task{ - NextDueDate: &nextYear, - Completions: []models.TaskCompletion{makeCompletion(1)}, - Status: &models.TaskStatus{Name: "Pending"}, // Reset after completion - Frequency: &models.TaskFrequency{Name: "Annually", Days: intPtr(365)}, - } - result := chain.Categorize(task, 30) - assert.Equal(t, ColumnUpcoming, result) - }) - - t.Run("monthly task due in 2 weeks should go to due_soon", func(t *testing.T) { - twoWeeks := time.Now().AddDate(0, 0, 14) - task := &models.Task{ - NextDueDate: &twoWeeks, - Completions: []models.TaskCompletion{makeCompletion(1)}, - Status: &models.TaskStatus{Name: "Pending"}, - Frequency: &models.TaskFrequency{Name: "Monthly", Days: intPtr(30)}, - } - result := chain.Categorize(task, 30) - assert.Equal(t, ColumnDueSoon, result) - }) - - t.Run("weekly task that is overdue should go to overdue", func(t *testing.T) { - yesterday := time.Now().AddDate(0, 0, -1) - task := &models.Task{ - NextDueDate: &yesterday, - Completions: []models.TaskCompletion{makeCompletion(1)}, - Status: &models.TaskStatus{Name: "Pending"}, - Frequency: &models.TaskFrequency{Name: "Weekly", Days: intPtr(7)}, - } - result := chain.Categorize(task, 30) - assert.Equal(t, ColumnOverdue, result) - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := categorization.CategorizeTask(tt.task, daysThreshold) + if result != tt.expected { + t.Errorf("CategorizeTask() = %v, expected %v", result, tt.expected) + } + }) + } } func TestCategorizeTasksIntoColumns(t *testing.T) { - now := time.Now() - pastDate := now.AddDate(0, 0, -5) - soonDate := now.AddDate(0, 0, 15) - futureDate := now.AddDate(0, 0, 60) + now := time.Now().UTC() + yesterday := now.AddDate(0, 0, -1) + in5Days := now.AddDate(0, 0, 5) + in60Days := now.AddDate(0, 0, 60) + daysThreshold := 30 - // Create tasks with proper IDs - task1 := makeTask(1) - task1.IsCancelled = true + tasks := []models.Task{ + {BaseModel: models.BaseModel{ID: 1}, IsCancelled: true}, // Cancelled + {BaseModel: models.BaseModel{ID: 2}, NextDueDate: nil, Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}}, // Completed + {BaseModel: models.BaseModel{ID: 3}, Status: &models.TaskStatus{Name: "In Progress"}}, // In Progress + {BaseModel: models.BaseModel{ID: 4}, NextDueDate: timePtr(yesterday)}, // Overdue + {BaseModel: models.BaseModel{ID: 5}, NextDueDate: timePtr(in5Days)}, // Due Soon + {BaseModel: models.BaseModel{ID: 6}, NextDueDate: timePtr(in60Days)}, // Upcoming + {BaseModel: models.BaseModel{ID: 7}}, // Upcoming (no due date) + } - task2 := makeTask(2) - task2.NextDueDate = nil - task2.Completions = []models.TaskCompletion{makeCompletion(1)} + result := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold) - task3 := makeTask(3) - task3.Status = &models.TaskStatus{Name: "In Progress"} - - task4 := makeTask(4) - task4.NextDueDate = &pastDate - task4.Status = &models.TaskStatus{Name: "Pending"} - - task5 := makeTask(5) - task5.NextDueDate = &soonDate - task5.Status = &models.TaskStatus{Name: "Pending"} - - task6 := makeTask(6) - task6.NextDueDate = &futureDate - task6.Status = &models.TaskStatus{Name: "Pending"} - - tasks := []models.Task{task1, task2, task3, task4, task5, task6} - - result := CategorizeTasksIntoColumns(tasks, 30) - - assert.Len(t, result[ColumnCancelled], 1) - assert.Equal(t, uint(1), result[ColumnCancelled][0].ID) - - assert.Len(t, result[ColumnCompleted], 1) - assert.Equal(t, uint(2), result[ColumnCompleted][0].ID) - - assert.Len(t, result[ColumnInProgress], 1) - assert.Equal(t, uint(3), result[ColumnInProgress][0].ID) - - assert.Len(t, result[ColumnOverdue], 1) - assert.Equal(t, uint(4), result[ColumnOverdue][0].ID) - - assert.Len(t, result[ColumnDueSoon], 1) - assert.Equal(t, uint(5), result[ColumnDueSoon][0].ID) - - assert.Len(t, result[ColumnUpcoming], 1) - assert.Equal(t, uint(6), result[ColumnUpcoming][0].ID) + // Check each column has the expected tasks + if len(result[categorization.ColumnCancelled]) != 1 || result[categorization.ColumnCancelled][0].ID != 1 { + t.Errorf("Expected task 1 in Cancelled column") + } + if len(result[categorization.ColumnCompleted]) != 1 || result[categorization.ColumnCompleted][0].ID != 2 { + t.Errorf("Expected task 2 in Completed column") + } + if len(result[categorization.ColumnInProgress]) != 1 || result[categorization.ColumnInProgress][0].ID != 3 { + t.Errorf("Expected task 3 in InProgress column") + } + if len(result[categorization.ColumnOverdue]) != 1 || result[categorization.ColumnOverdue][0].ID != 4 { + t.Errorf("Expected task 4 in Overdue column") + } + if len(result[categorization.ColumnDueSoon]) != 1 || result[categorization.ColumnDueSoon][0].ID != 5 { + t.Errorf("Expected task 5 in DueSoon column") + } + if len(result[categorization.ColumnUpcoming]) != 2 { + t.Errorf("Expected 2 tasks in Upcoming column, got %d", len(result[categorization.ColumnUpcoming])) + } } -func TestDefaultThreshold(t *testing.T) { +func TestDetermineKanbanColumn_ReturnsString(t *testing.T) { + task := &models.Task{IsCancelled: true} + result := categorization.DetermineKanbanColumn(task, 30) + + if result != "cancelled_tasks" { + t.Errorf("DetermineKanbanColumn() = %v, expected %v", result, "cancelled_tasks") + } +} + +func TestKanbanColumnConstants(t *testing.T) { + // Verify column string values match expected API values + tests := []struct { + column categorization.KanbanColumn + expected string + }{ + {categorization.ColumnOverdue, "overdue_tasks"}, + {categorization.ColumnDueSoon, "due_soon_tasks"}, + {categorization.ColumnUpcoming, "upcoming_tasks"}, + {categorization.ColumnInProgress, "in_progress_tasks"}, + {categorization.ColumnCompleted, "completed_tasks"}, + {categorization.ColumnCancelled, "cancelled_tasks"}, + } + + for _, tt := range tests { + if tt.column.String() != tt.expected { + t.Errorf("Column %v.String() = %v, expected %v", tt.column, tt.column.String(), tt.expected) + } + } +} + +func TestNewContext_DefaultThreshold(t *testing.T) { task := &models.Task{} - // Test that 0 or negative threshold defaults to 30 - ctx1 := NewContext(task, 0) - assert.Equal(t, 30, ctx1.DaysThreshold) + // Zero threshold should default to 30 + ctx := categorization.NewContext(task, 0) + if ctx.DaysThreshold != 30 { + t.Errorf("NewContext with 0 threshold should default to 30, got %d", ctx.DaysThreshold) + } - ctx2 := NewContext(task, -5) - assert.Equal(t, 30, ctx2.DaysThreshold) + // Negative threshold should default to 30 + ctx = categorization.NewContext(task, -5) + if ctx.DaysThreshold != 30 { + t.Errorf("NewContext with negative threshold should default to 30, got %d", ctx.DaysThreshold) + } - ctx3 := NewContext(task, 14) - assert.Equal(t, 14, ctx3.DaysThreshold) -} - -// Helper to create int pointer -func intPtr(v int) *int { - return &v + // Positive threshold should be used + ctx = categorization.NewContext(task, 45) + if ctx.DaysThreshold != 45 { + t.Errorf("NewContext with 45 threshold should be 45, got %d", ctx.DaysThreshold) + } } diff --git a/internal/task/consistency_test.go b/internal/task/consistency_test.go new file mode 100644 index 0000000..88b94ea --- /dev/null +++ b/internal/task/consistency_test.go @@ -0,0 +1,668 @@ +// Package task provides consistency tests that verify all three layers +// (predicates, scopes, and categorization) return identical results. +// +// These tests are critical for ensuring the DRY architecture is maintained. +// If any of these tests fail, it means the three layers have diverged and +// will produce inconsistent results in different parts of the application. +package task_test + +import ( + "os" + "testing" + "time" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/treytartt/casera-api/internal/models" + "github.com/treytartt/casera-api/internal/task/categorization" + "github.com/treytartt/casera-api/internal/task/predicates" + "github.com/treytartt/casera-api/internal/task/scopes" +) + +// testDB holds the database connection for integration tests +var testDB *gorm.DB + +// testUserID is a user ID that exists in the database for foreign key constraints +var testUserID uint = 1 + +// TestMain sets up the database connection for all tests in this package +func TestMain(m *testing.M) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + dsn = "host=localhost user=postgres password=postgres dbname=mycrib_test port=5432 sslmode=disable" + } + + var err error + testDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + println("Skipping consistency integration tests: database not available") + println("Set TEST_DATABASE_URL to run these tests") + os.Exit(0) + } + + sqlDB, err := testDB.DB() + if err != nil || sqlDB.Ping() != nil { + println("Failed to connect to database") + os.Exit(0) + } + + println("Database connected, running consistency tests...") + + code := m.Run() + cleanupTestData() + os.Exit(code) +} + +func cleanupTestData() { + if testDB == nil { + return + } + testDB.Exec("DELETE FROM task_taskcompletion WHERE task_id IN (SELECT id FROM task_task WHERE title LIKE 'consistency_test_%')") + testDB.Exec("DELETE FROM task_task WHERE title LIKE 'consistency_test_%'") + testDB.Exec("DELETE FROM residence_residence WHERE name LIKE 'consistency_test_%'") +} + +func timePtr(t time.Time) *time.Time { + return &t +} + +func createResidence(t *testing.T) uint { + residence := &models.Residence{ + Name: "consistency_test_" + time.Now().Format("20060102150405.000"), + OwnerID: testUserID, + IsActive: true, + } + if err := testDB.Create(residence).Error; err != nil { + t.Fatalf("Failed to create residence: %v", err) + } + return residence.ID +} + +func createTask(t *testing.T, residenceID uint, task *models.Task) *models.Task { + task.ResidenceID = residenceID + task.Title = "consistency_test_" + task.Title + task.CreatedByID = testUserID + if err := testDB.Create(task).Error; err != nil { + t.Fatalf("Failed to create task: %v", err) + } + return task +} + +func createCompletion(t *testing.T, taskID uint) { + completion := &models.TaskCompletion{ + TaskID: taskID, + CompletedByID: testUserID, + CompletedAt: time.Now().UTC(), + } + if err := testDB.Create(completion).Error; err != nil { + t.Fatalf("Failed to create completion: %v", err) + } +} + +// getInProgressStatusID returns the ID of the "In Progress" status +func getInProgressStatusID(t *testing.T) *uint { + var status models.TaskStatus + if err := testDB.Where("name = ?", "In Progress").First(&status).Error; err != nil { + t.Logf("In Progress status not found, skipping in-progress tests") + return nil + } + return &status.ID +} + +// TaskTestCase defines a test scenario with expected categorization +type TaskTestCase struct { + Name string + Task *models.Task + HasCompletion bool + ExpectedColumn categorization.KanbanColumn + // Expected predicate results + ExpectCompleted bool + ExpectActive bool + ExpectOverdue bool + ExpectDueSoon bool + ExpectUpcoming bool + ExpectInProgress bool +} + +// TestAllThreeLayersMatch is the master consistency test. +// It creates tasks in the database, then verifies that: +// 1. Predicates return the expected boolean values +// 2. Categorization returns the expected kanban column +// 3. Scopes return the same tasks that predicates would filter +func TestAllThreeLayersMatch(t *testing.T) { + if testDB == nil { + t.Skip("Database not available") + } + + residenceID := createResidence(t) + defer cleanupTestData() + + now := time.Now().UTC() + yesterday := now.AddDate(0, 0, -1) + in5Days := now.AddDate(0, 0, 5) + in60Days := now.AddDate(0, 0, 60) + daysThreshold := 30 + + inProgressStatusID := getInProgressStatusID(t) + + // Define all test cases with expected results for each layer + testCases := []TaskTestCase{ + { + Name: "overdue_active", + Task: &models.Task{ + Title: "overdue_active", + NextDueDate: timePtr(yesterday), + IsCancelled: false, + IsArchived: false, + }, + ExpectedColumn: categorization.ColumnOverdue, + ExpectCompleted: false, + ExpectActive: true, + ExpectOverdue: true, + ExpectDueSoon: false, + ExpectUpcoming: false, + }, + { + Name: "due_soon_active", + Task: &models.Task{ + Title: "due_soon_active", + NextDueDate: timePtr(in5Days), + IsCancelled: false, + IsArchived: false, + }, + ExpectedColumn: categorization.ColumnDueSoon, + ExpectCompleted: false, + ExpectActive: true, + ExpectOverdue: false, + ExpectDueSoon: true, + ExpectUpcoming: false, + }, + { + Name: "upcoming_far_future", + Task: &models.Task{ + Title: "upcoming_far_future", + NextDueDate: timePtr(in60Days), + IsCancelled: false, + IsArchived: false, + }, + ExpectedColumn: categorization.ColumnUpcoming, + ExpectCompleted: false, + ExpectActive: true, + ExpectOverdue: false, + ExpectDueSoon: false, + ExpectUpcoming: true, + }, + { + Name: "upcoming_no_due_date", + Task: &models.Task{ + Title: "upcoming_no_due_date", + NextDueDate: nil, + DueDate: nil, + IsCancelled: false, + IsArchived: false, + }, + ExpectedColumn: categorization.ColumnUpcoming, + ExpectCompleted: false, + ExpectActive: true, + ExpectOverdue: false, + ExpectDueSoon: false, + ExpectUpcoming: true, + }, + { + Name: "completed_one_time", + Task: &models.Task{ + Title: "completed_one_time", + NextDueDate: nil, // No next due date + DueDate: timePtr(yesterday), + IsCancelled: false, + IsArchived: false, + }, + HasCompletion: true, // Will create a completion + ExpectedColumn: categorization.ColumnCompleted, + ExpectCompleted: true, + ExpectActive: true, + ExpectOverdue: false, // Completed tasks are not overdue + ExpectDueSoon: false, + ExpectUpcoming: false, + }, + { + Name: "cancelled_task", + Task: &models.Task{ + Title: "cancelled_task", + NextDueDate: timePtr(yesterday), // Would be overdue if not cancelled + IsCancelled: true, + IsArchived: false, + }, + ExpectedColumn: categorization.ColumnCancelled, + ExpectCompleted: false, + ExpectActive: false, // Cancelled is not active + ExpectOverdue: false, // Cancelled tasks are not overdue + ExpectDueSoon: false, + ExpectUpcoming: false, + }, + { + Name: "archived_task", + Task: &models.Task{ + Title: "archived_task", + NextDueDate: timePtr(yesterday), + IsCancelled: false, + IsArchived: true, + }, + // Archived tasks don't appear in kanban (filtered out before categorization) + // but we test predicates still work + ExpectedColumn: categorization.ColumnOverdue, // Chain doesn't check archived + ExpectCompleted: false, + ExpectActive: false, // Archived is not active + ExpectOverdue: false, // Archived tasks are not overdue (IsOverdue checks IsActive) + ExpectDueSoon: false, + ExpectUpcoming: false, + }, + { + Name: "recurring_with_completion", + Task: &models.Task{ + Title: "recurring_with_completion", + NextDueDate: timePtr(in5Days), // Has next due date (recurring) + IsCancelled: false, + IsArchived: false, + }, + HasCompletion: true, + ExpectedColumn: categorization.ColumnDueSoon, // Not completed because NextDueDate is set + ExpectCompleted: false, // Has completion but NextDueDate is set + ExpectActive: true, + ExpectOverdue: false, + ExpectDueSoon: true, + ExpectUpcoming: false, + }, + { + Name: "overdue_uses_duedate_fallback", + Task: &models.Task{ + Title: "overdue_uses_duedate_fallback", + NextDueDate: nil, + DueDate: timePtr(yesterday), // Falls back to DueDate + IsCancelled: false, + IsArchived: false, + }, + ExpectedColumn: categorization.ColumnOverdue, + ExpectCompleted: false, + ExpectActive: true, + ExpectOverdue: true, + ExpectDueSoon: false, + ExpectUpcoming: false, + }, + } + + // Add in-progress test case only if status exists + if inProgressStatusID != nil { + testCases = append(testCases, TaskTestCase{ + Name: "in_progress_overdue", + Task: &models.Task{ + Title: "in_progress_overdue", + NextDueDate: timePtr(yesterday), // Would be overdue + StatusID: inProgressStatusID, + IsCancelled: false, + IsArchived: false, + }, + ExpectedColumn: categorization.ColumnInProgress, // In Progress takes priority + ExpectCompleted: false, + ExpectActive: true, + ExpectOverdue: true, // Predicate says overdue (doesn't check status) + ExpectDueSoon: false, + ExpectUpcoming: false, + ExpectInProgress: true, + }) + } + + // Create all tasks in database + createdTasks := make(map[string]*models.Task) + for _, tc := range testCases { + task := createTask(t, residenceID, tc.Task) + if tc.HasCompletion { + createCompletion(t, task.ID) + } + createdTasks[tc.Name] = task + } + + // Reload all tasks with preloads for predicate testing + var allTasks []models.Task + err := testDB. + Preload("Completions"). + Preload("Status"). + Where("residence_id = ?", residenceID). + Find(&allTasks).Error + if err != nil { + t.Fatalf("Failed to load tasks: %v", err) + } + + // Create a map for easy lookup + taskMap := make(map[string]*models.Task) + for i := range allTasks { + // Strip the prefix for lookup + name := allTasks[i].Title[len("consistency_test_"):] + taskMap[name] = &allTasks[i] + } + + // Test each case + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + task := taskMap[tc.Name] + if task == nil { + t.Fatalf("Task %s not found", tc.Name) + } + + // ========== TEST PREDICATES ========== + t.Run("predicates", func(t *testing.T) { + if got := predicates.IsCompleted(task); got != tc.ExpectCompleted { + t.Errorf("IsCompleted() = %v, want %v", got, tc.ExpectCompleted) + } + if got := predicates.IsActive(task); got != tc.ExpectActive { + t.Errorf("IsActive() = %v, want %v", got, tc.ExpectActive) + } + if got := predicates.IsOverdue(task, now); got != tc.ExpectOverdue { + t.Errorf("IsOverdue() = %v, want %v", got, tc.ExpectOverdue) + } + if got := predicates.IsDueSoon(task, now, daysThreshold); got != tc.ExpectDueSoon { + t.Errorf("IsDueSoon() = %v, want %v", got, tc.ExpectDueSoon) + } + if got := predicates.IsUpcoming(task, now, daysThreshold); got != tc.ExpectUpcoming { + t.Errorf("IsUpcoming() = %v, want %v", got, tc.ExpectUpcoming) + } + if tc.ExpectInProgress { + if got := predicates.IsInProgress(task); got != tc.ExpectInProgress { + t.Errorf("IsInProgress() = %v, want %v", got, tc.ExpectInProgress) + } + } + }) + + // ========== TEST CATEGORIZATION ========== + t.Run("categorization", func(t *testing.T) { + got := categorization.CategorizeTask(task, daysThreshold) + if got != tc.ExpectedColumn { + t.Errorf("CategorizeTask() = %v, want %v", got, tc.ExpectedColumn) + } + }) + }) + } + + // ========== TEST SCOPES MATCH PREDICATES ========== + // This is the critical test: query with scopes and verify results match predicate filtering + + t.Run("scopes_match_predicates", func(t *testing.T) { + // Test ScopeActive + t.Run("ScopeActive", func(t *testing.T) { + var scopeResults []models.Task + testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeActive). + Find(&scopeResults) + + predicateCount := 0 + for _, task := range allTasks { + if predicates.IsActive(&task) { + predicateCount++ + } + } + + if len(scopeResults) != predicateCount { + t.Errorf("ScopeActive returned %d, predicates found %d", len(scopeResults), predicateCount) + } + }) + + // Test ScopeCompleted + t.Run("ScopeCompleted", func(t *testing.T) { + var scopeResults []models.Task + testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeCompleted). + Find(&scopeResults) + + predicateCount := 0 + for _, task := range allTasks { + if predicates.IsCompleted(&task) { + predicateCount++ + } + } + + if len(scopeResults) != predicateCount { + t.Errorf("ScopeCompleted returned %d, predicates found %d", len(scopeResults), predicateCount) + } + }) + + // Test ScopeOverdue + t.Run("ScopeOverdue", func(t *testing.T) { + var scopeResults []models.Task + testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)). + Find(&scopeResults) + + predicateCount := 0 + for _, task := range allTasks { + if predicates.IsOverdue(&task, now) { + predicateCount++ + } + } + + if len(scopeResults) != predicateCount { + t.Errorf("ScopeOverdue returned %d, predicates found %d", len(scopeResults), predicateCount) + t.Logf("Scope results: %v", getTaskNames(scopeResults)) + t.Logf("Predicate matches: %v", getPredicateMatches(allTasks, func(task *models.Task) bool { + return predicates.IsOverdue(task, now) + })) + } + }) + + // Test ScopeDueSoon + t.Run("ScopeDueSoon", func(t *testing.T) { + var scopeResults []models.Task + testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeDueSoon(now, daysThreshold)). + Find(&scopeResults) + + predicateCount := 0 + for _, task := range allTasks { + if predicates.IsDueSoon(&task, now, daysThreshold) { + predicateCount++ + } + } + + if len(scopeResults) != predicateCount { + t.Errorf("ScopeDueSoon returned %d, predicates found %d", len(scopeResults), predicateCount) + } + }) + + // Test ScopeUpcoming + t.Run("ScopeUpcoming", func(t *testing.T) { + var scopeResults []models.Task + testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeUpcoming(now, daysThreshold)). + Find(&scopeResults) + + predicateCount := 0 + for _, task := range allTasks { + if predicates.IsUpcoming(&task, now, daysThreshold) { + predicateCount++ + } + } + + if len(scopeResults) != predicateCount { + t.Errorf("ScopeUpcoming returned %d, predicates found %d", len(scopeResults), predicateCount) + } + }) + + // Test ScopeInProgress (if status exists) + if inProgressStatusID != nil { + t.Run("ScopeInProgress", func(t *testing.T) { + var scopeResults []models.Task + testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeInProgress). + Find(&scopeResults) + + predicateCount := 0 + for _, task := range allTasks { + if predicates.IsInProgress(&task) { + predicateCount++ + } + } + + if len(scopeResults) != predicateCount { + t.Errorf("ScopeInProgress returned %d, predicates found %d", len(scopeResults), predicateCount) + } + }) + } + }) + + // ========== TEST CATEGORIZATION MATCHES SCOPES FOR KANBAN ========== + // Verify that tasks categorized into each column match what scopes would return + + t.Run("categorization_matches_scopes", func(t *testing.T) { + // Get categorization results + categorized := categorization.CategorizeTasksIntoColumns(allTasks, daysThreshold) + + // Compare overdue column with scope + // NOTE: Scopes return tasks based on date criteria only. + // Categorization uses priority order (In Progress > Overdue). + // So a task that is overdue by date but "In Progress" won't be in ColumnOverdue. + // We need to compare scope results MINUS those with higher-priority categorization. + t.Run("overdue_column", func(t *testing.T) { + var scopeResults []models.Task + testDB.Model(&models.Task{}). + Preload("Status"). + Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)). + Find(&scopeResults) + + // Filter scope results to exclude tasks that would be categorized differently + // (i.e., tasks that are In Progress - higher priority than Overdue) + scopeOverdueNotInProgress := 0 + for _, task := range scopeResults { + if !predicates.IsInProgress(&task) { + scopeOverdueNotInProgress++ + } + } + + // Count active overdue tasks in categorization + activeOverdue := 0 + for _, task := range categorized[categorization.ColumnOverdue] { + if predicates.IsActive(&task) { + activeOverdue++ + } + } + + if scopeOverdueNotInProgress != activeOverdue { + t.Errorf("Overdue: scope returned %d (excluding in-progress), categorization has %d active", + scopeOverdueNotInProgress, activeOverdue) + } + }) + + // Compare due soon column with scope + t.Run("due_soon_column", func(t *testing.T) { + var scopeResults []models.Task + testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeDueSoon(now, daysThreshold)). + Find(&scopeResults) + + activeDueSoon := 0 + for _, task := range categorized[categorization.ColumnDueSoon] { + if predicates.IsActive(&task) { + activeDueSoon++ + } + } + + if len(scopeResults) != activeDueSoon { + t.Errorf("DueSoon: scope returned %d, categorization has %d active", + len(scopeResults), activeDueSoon) + } + }) + + // Compare completed column with scope + t.Run("completed_column", func(t *testing.T) { + var scopeResults []models.Task + testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeCompleted). + Find(&scopeResults) + + if len(scopeResults) != len(categorized[categorization.ColumnCompleted]) { + t.Errorf("Completed: scope returned %d, categorization has %d", + len(scopeResults), len(categorized[categorization.ColumnCompleted])) + } + }) + }) +} + +// TestSameDayOverdueConsistency is a regression test for the DATE vs TIMESTAMP bug. +// It verifies all three layers handle same-day tasks consistently. +func TestSameDayOverdueConsistency(t *testing.T) { + if testDB == nil { + t.Skip("Database not available") + } + + residenceID := createResidence(t) + defer cleanupTestData() + + // Create a task due at midnight today + now := time.Now().UTC() + todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + + task := createTask(t, residenceID, &models.Task{ + Title: "same_day_midnight", + NextDueDate: timePtr(todayMidnight), + IsCancelled: false, + IsArchived: false, + }) + + // Reload with preloads + var loadedTask models.Task + testDB.Preload("Completions").Preload("Status").First(&loadedTask, task.ID) + + // All three layers should agree + predicateResult := predicates.IsOverdue(&loadedTask, now) + + var scopeResults []models.Task + testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)). + Find(&scopeResults) + scopeResult := len(scopeResults) == 1 + + categorizationResult := categorization.CategorizeTask(&loadedTask, 30) == categorization.ColumnOverdue + + // If current time is after midnight, all should say overdue + if now.After(todayMidnight) { + if !predicateResult { + t.Error("Predicate says NOT overdue, but time is after midnight") + } + if !scopeResult { + t.Error("Scope says NOT overdue, but time is after midnight") + } + if !categorizationResult { + t.Error("Categorization says NOT overdue, but time is after midnight") + } + } + + // Most importantly: all three must agree + if predicateResult != scopeResult { + t.Errorf("INCONSISTENCY: predicate=%v, scope=%v", predicateResult, scopeResult) + } + if predicateResult != categorizationResult { + t.Errorf("INCONSISTENCY: predicate=%v, categorization=%v", predicateResult, categorizationResult) + } +} + +// Helper functions + +func getTaskNames(tasks []models.Task) []string { + names := make([]string, len(tasks)) + for i, t := range tasks { + names[i] = t.Title + } + return names +} + +func getPredicateMatches(tasks []models.Task, predicate func(*models.Task) bool) []string { + var names []string + for _, t := range tasks { + if predicate(&t) { + names = append(names, t.Title) + } + } + return names +} diff --git a/internal/task/predicates/predicates.go b/internal/task/predicates/predicates.go new file mode 100644 index 0000000..e81f10a --- /dev/null +++ b/internal/task/predicates/predicates.go @@ -0,0 +1,189 @@ +// 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 has status "In Progress". +// +// SQL equivalent (in scopes.go ScopeInProgress): +// +// task_taskstatus.name = 'In Progress' +func IsInProgress(task *models.Task) bool { + return task.Status != nil && task.Status.Name == "In Progress" +} + +// ============================================================================= +// 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) +} diff --git a/internal/task/predicates/predicates_test.go b/internal/task/predicates/predicates_test.go new file mode 100644 index 0000000..24e7411 --- /dev/null +++ b/internal/task/predicates/predicates_test.go @@ -0,0 +1,522 @@ +package predicates_test + +import ( + "testing" + "time" + + "github.com/treytartt/casera-api/internal/models" + "github.com/treytartt/casera-api/internal/task/predicates" +) + +// Helper to create a time pointer +func timePtr(t time.Time) *time.Time { + return &t +} + +func TestIsCompleted(t *testing.T) { + tests := []struct { + name string + task *models.Task + expected bool + }{ + { + name: "completed: NextDueDate nil with completions", + task: &models.Task{ + NextDueDate: nil, + Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, + }, + expected: true, + }, + { + name: "not completed: NextDueDate set with completions", + task: &models.Task{ + NextDueDate: timePtr(time.Now().AddDate(0, 0, 7)), + Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, + }, + expected: false, + }, + { + name: "not completed: NextDueDate nil without completions", + task: &models.Task{ + NextDueDate: nil, + Completions: []models.TaskCompletion{}, + }, + expected: false, + }, + { + name: "not completed: NextDueDate set without completions", + task: &models.Task{ + NextDueDate: timePtr(time.Now().AddDate(0, 0, 7)), + Completions: []models.TaskCompletion{}, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := predicates.IsCompleted(tt.task) + if result != tt.expected { + t.Errorf("IsCompleted() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestIsActive(t *testing.T) { + tests := []struct { + name string + task *models.Task + expected bool + }{ + { + name: "active: not cancelled, not archived", + task: &models.Task{IsCancelled: false, IsArchived: false}, + expected: true, + }, + { + name: "not active: cancelled", + task: &models.Task{IsCancelled: true, IsArchived: false}, + expected: false, + }, + { + name: "not active: archived", + task: &models.Task{IsCancelled: false, IsArchived: true}, + expected: false, + }, + { + name: "not active: both cancelled and archived", + task: &models.Task{IsCancelled: true, IsArchived: true}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := predicates.IsActive(tt.task) + if result != tt.expected { + t.Errorf("IsActive() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestIsInProgress(t *testing.T) { + inProgressStatus := &models.TaskStatus{Name: "In Progress"} + pendingStatus := &models.TaskStatus{Name: "Pending"} + + tests := []struct { + name string + task *models.Task + expected bool + }{ + { + name: "in progress: status is In Progress", + task: &models.Task{Status: inProgressStatus}, + expected: true, + }, + { + name: "not in progress: status is Pending", + task: &models.Task{Status: pendingStatus}, + expected: false, + }, + { + name: "not in progress: no status", + task: &models.Task{Status: nil}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := predicates.IsInProgress(tt.task) + if result != tt.expected { + t.Errorf("IsInProgress() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestEffectiveDate(t *testing.T) { + now := time.Now() + nextWeek := now.AddDate(0, 0, 7) + nextMonth := now.AddDate(0, 1, 0) + + tests := []struct { + name string + task *models.Task + expected *time.Time + }{ + { + name: "prefers NextDueDate when both set", + task: &models.Task{ + NextDueDate: timePtr(nextWeek), + DueDate: timePtr(nextMonth), + }, + expected: timePtr(nextWeek), + }, + { + name: "falls back to DueDate when NextDueDate nil", + task: &models.Task{ + NextDueDate: nil, + DueDate: timePtr(nextMonth), + }, + expected: timePtr(nextMonth), + }, + { + name: "returns nil when both nil", + task: &models.Task{ + NextDueDate: nil, + DueDate: nil, + }, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := predicates.EffectiveDate(tt.task) + if tt.expected == nil { + if result != nil { + t.Errorf("EffectiveDate() = %v, expected nil", result) + } + } else { + if result == nil { + t.Errorf("EffectiveDate() = nil, expected %v", tt.expected) + } else if !result.Equal(*tt.expected) { + t.Errorf("EffectiveDate() = %v, expected %v", result, tt.expected) + } + } + }) + } +} + +func TestIsOverdue(t *testing.T) { + now := time.Now().UTC() + yesterday := now.AddDate(0, 0, -1) + tomorrow := now.AddDate(0, 0, 1) + + tests := []struct { + name string + task *models.Task + now time.Time + expected bool + }{ + { + name: "overdue: effective date in past", + task: &models.Task{ + NextDueDate: timePtr(yesterday), + IsCancelled: false, + IsArchived: false, + Completions: []models.TaskCompletion{}, + }, + now: now, + expected: true, + }, + { + name: "not overdue: effective date in future", + task: &models.Task{ + NextDueDate: timePtr(tomorrow), + IsCancelled: false, + IsArchived: false, + Completions: []models.TaskCompletion{}, + }, + now: now, + expected: false, + }, + { + name: "not overdue: cancelled task", + task: &models.Task{ + NextDueDate: timePtr(yesterday), + IsCancelled: true, + IsArchived: false, + Completions: []models.TaskCompletion{}, + }, + now: now, + expected: false, + }, + { + name: "not overdue: archived task", + task: &models.Task{ + NextDueDate: timePtr(yesterday), + IsCancelled: false, + IsArchived: true, + Completions: []models.TaskCompletion{}, + }, + now: now, + expected: false, + }, + { + name: "not overdue: completed task (NextDueDate nil with completions)", + task: &models.Task{ + NextDueDate: nil, + DueDate: timePtr(yesterday), + IsCancelled: false, + IsArchived: false, + Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, + }, + now: now, + expected: false, + }, + { + name: "not overdue: no due date", + task: &models.Task{ + NextDueDate: nil, + DueDate: nil, + IsCancelled: false, + IsArchived: false, + Completions: []models.TaskCompletion{}, + }, + now: now, + expected: false, + }, + { + name: "overdue: uses DueDate when NextDueDate nil (no completions)", + task: &models.Task{ + NextDueDate: nil, + DueDate: timePtr(yesterday), + IsCancelled: false, + IsArchived: false, + Completions: []models.TaskCompletion{}, + }, + now: now, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := predicates.IsOverdue(tt.task, tt.now) + if result != tt.expected { + t.Errorf("IsOverdue() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestIsDueSoon(t *testing.T) { + now := time.Now().UTC() + yesterday := now.AddDate(0, 0, -1) + in5Days := now.AddDate(0, 0, 5) + in60Days := now.AddDate(0, 0, 60) + + tests := []struct { + name string + task *models.Task + now time.Time + daysThreshold int + expected bool + }{ + { + name: "due soon: within threshold", + task: &models.Task{ + NextDueDate: timePtr(in5Days), + IsCancelled: false, + IsArchived: false, + Completions: []models.TaskCompletion{}, + }, + now: now, + daysThreshold: 30, + expected: true, + }, + { + name: "not due soon: beyond threshold", + task: &models.Task{ + NextDueDate: timePtr(in60Days), + IsCancelled: false, + IsArchived: false, + Completions: []models.TaskCompletion{}, + }, + now: now, + daysThreshold: 30, + expected: false, + }, + { + name: "not due soon: overdue (in past)", + task: &models.Task{ + NextDueDate: timePtr(yesterday), + IsCancelled: false, + IsArchived: false, + Completions: []models.TaskCompletion{}, + }, + now: now, + daysThreshold: 30, + expected: false, + }, + { + name: "not due soon: cancelled", + task: &models.Task{ + NextDueDate: timePtr(in5Days), + IsCancelled: true, + IsArchived: false, + Completions: []models.TaskCompletion{}, + }, + now: now, + daysThreshold: 30, + expected: false, + }, + { + name: "not due soon: completed", + task: &models.Task{ + NextDueDate: nil, + DueDate: timePtr(in5Days), + IsCancelled: false, + IsArchived: false, + Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, + }, + now: now, + daysThreshold: 30, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := predicates.IsDueSoon(tt.task, tt.now, tt.daysThreshold) + if result != tt.expected { + t.Errorf("IsDueSoon() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestIsUpcoming(t *testing.T) { + now := time.Now().UTC() + in5Days := now.AddDate(0, 0, 5) + in60Days := now.AddDate(0, 0, 60) + + tests := []struct { + name string + task *models.Task + now time.Time + daysThreshold int + expected bool + }{ + { + name: "upcoming: beyond threshold", + task: &models.Task{ + NextDueDate: timePtr(in60Days), + IsCancelled: false, + IsArchived: false, + Completions: []models.TaskCompletion{}, + }, + now: now, + daysThreshold: 30, + expected: true, + }, + { + name: "upcoming: no due date", + task: &models.Task{ + NextDueDate: nil, + DueDate: nil, + IsCancelled: false, + IsArchived: false, + Completions: []models.TaskCompletion{}, + }, + now: now, + daysThreshold: 30, + expected: true, + }, + { + name: "not upcoming: within due soon threshold", + task: &models.Task{ + NextDueDate: timePtr(in5Days), + IsCancelled: false, + IsArchived: false, + Completions: []models.TaskCompletion{}, + }, + now: now, + daysThreshold: 30, + expected: false, + }, + { + name: "not upcoming: cancelled", + task: &models.Task{ + NextDueDate: timePtr(in60Days), + IsCancelled: true, + IsArchived: false, + Completions: []models.TaskCompletion{}, + }, + now: now, + daysThreshold: 30, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := predicates.IsUpcoming(tt.task, tt.now, tt.daysThreshold) + if result != tt.expected { + t.Errorf("IsUpcoming() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestHasCompletions(t *testing.T) { + tests := []struct { + name string + task *models.Task + expected bool + }{ + { + name: "has completions", + task: &models.Task{Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}}, + expected: true, + }, + { + name: "no completions", + task: &models.Task{Completions: []models.TaskCompletion{}}, + expected: false, + }, + { + name: "nil completions", + task: &models.Task{Completions: nil}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := predicates.HasCompletions(tt.task) + if result != tt.expected { + t.Errorf("HasCompletions() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestIsRecurring(t *testing.T) { + days := 7 + + tests := []struct { + name string + task *models.Task + expected bool + }{ + { + name: "recurring: frequency with days", + task: &models.Task{Frequency: &models.TaskFrequency{Days: &days}}, + expected: true, + }, + { + name: "not recurring: frequency without days (one-time)", + task: &models.Task{Frequency: &models.TaskFrequency{Days: nil}}, + expected: false, + }, + { + name: "not recurring: no frequency", + task: &models.Task{Frequency: nil}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := predicates.IsRecurring(tt.task) + if result != tt.expected { + t.Errorf("IsRecurring() = %v, expected %v", result, tt.expected) + } + }) + } +} diff --git a/internal/task/scopes/scopes.go b/internal/task/scopes/scopes.go new file mode 100644 index 0000000..c50037d --- /dev/null +++ b/internal/task/scopes/scopes.go @@ -0,0 +1,274 @@ +// 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 with status "In Progress". +// +// Predicate equivalent: IsInProgress(task) +// +// SQL: Joins task_taskstatus and filters by name = 'In Progress' +func ScopeInProgress(db *gorm.DB) *gorm.DB { + return db.Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id"). + Where("task_taskstatus.name = ?", "In Progress") +} + +// ScopeNotInProgress excludes tasks with status "In Progress". +// +// Predicate equivalent: !IsInProgress(task) +func ScopeNotInProgress(db *gorm.DB) *gorm.DB { + return db.Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id"). + Where("task_taskstatus.name != ? OR task_taskstatus.name IS NULL", "In Progress") +} + +// ============================================================================= +// 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 given time, and it's active and not completed. +// +// Predicate equivalent: IsOverdue(task, now) +// +// SQL: COALESCE(next_due_date, due_date) < ?::timestamp AND active AND not_completed +// +// NOTE: We explicitly cast to timestamp because PostgreSQL DATE columns compared +// against string literals (which is how GORM passes time.Time) use date comparison, +// not timestamp comparison. For example: +// - '2025-12-07'::date < '2025-12-07 17:00:00' = false (compares dates only) +// - '2025-12-07'::date < '2025-12-07 17:00:00'::timestamp = true (compares timestamp) +func ScopeOverdue(now time.Time) func(db *gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Scopes(ScopeActive, ScopeNotCompleted). + Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", now) + } +} + +// ScopeDueSoon returns a scope for tasks due within the threshold. +// +// A task is "due soon" when its effective date is >= now AND < (now + threshold), +// and it's active and not completed. +// +// Predicate equivalent: IsDueSoon(task, now, daysThreshold) +// +// SQL: COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp +// +// AND COALESCE(next_due_date, due_date)::timestamp < ?::timestamp +// AND active AND not_completed +// +// NOTE: We explicitly cast to timestamp for consistent comparison with DATE columns. +// See ScopeOverdue for detailed explanation. +func ScopeDueSoon(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + threshold := now.AddDate(0, 0, daysThreshold) + return db.Scopes(ScopeActive, ScopeNotCompleted). + Where("COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp", now). + Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", threshold) + } +} + +// ScopeUpcoming returns a scope for tasks due after the threshold or with no due date. +// +// A task is "upcoming" when its effective date is >= (now + threshold) OR is null, +// and it's active and not completed. +// +// Predicate equivalent: IsUpcoming(task, now, daysThreshold) +// +// SQL: (COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp OR (next_due_date IS NULL AND due_date IS NULL)) +// +// AND active AND not_completed +// +// NOTE: We explicitly cast to timestamp for consistent comparison with DATE columns. +// See ScopeOverdue for detailed explanation. +func ScopeUpcoming(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + threshold := now.AddDate(0, 0, daysThreshold) + return db.Scopes(ScopeActive, ScopeNotCompleted). + Where( + "COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp OR (next_due_date IS NULL AND due_date IS NULL)", + threshold, + ) + } +} + +// ScopeDueInRange returns a scope for tasks with effective date in a range. +// +// SQL: COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp +// +// AND COALESCE(next_due_date, due_date)::timestamp < ?::timestamp +// +// NOTE: We explicitly cast to timestamp for consistent comparison with DATE columns. +// See ScopeOverdue for detailed explanation. +func ScopeDueInRange(start, end time.Time) func(db *gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db. + Where("COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp", start). + Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", end) + } +} + +// 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") +} diff --git a/internal/task/scopes/scopes_test.go b/internal/task/scopes/scopes_test.go new file mode 100644 index 0000000..3d6586e --- /dev/null +++ b/internal/task/scopes/scopes_test.go @@ -0,0 +1,706 @@ +package scopes_test + +import ( + "os" + "testing" + "time" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/treytartt/casera-api/internal/models" + "github.com/treytartt/casera-api/internal/task/predicates" + "github.com/treytartt/casera-api/internal/task/scopes" +) + +// testDB holds the database connection for integration tests +var testDB *gorm.DB + +// TestMain sets up the database connection for all tests in this package +func TestMain(m *testing.M) { + // Get database URL from environment or use default + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + dsn = "host=localhost user=postgres password=postgres dbname=mycrib_test port=5432 sslmode=disable" + } + + var err error + testDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + // Print message and skip tests if database is not available + println("Skipping scope integration tests: database not available") + println("Set TEST_DATABASE_URL to run these tests") + println("Error:", err.Error()) + os.Exit(0) + } + + // Verify connection works + sqlDB, err := testDB.DB() + if err != nil { + println("Failed to get underlying DB:", err.Error()) + os.Exit(0) + } + if err := sqlDB.Ping(); err != nil { + println("Failed to ping database:", err.Error()) + os.Exit(0) + } + + println("Database connected successfully, running integration tests...") + + // Run migrations for test tables + err = testDB.AutoMigrate( + &models.Task{}, + &models.TaskCompletion{}, + &models.TaskStatus{}, + &models.Residence{}, + ) + if err != nil { + os.Exit(1) + } + + // Run tests + code := m.Run() + + // Cleanup + cleanupTestData() + + os.Exit(code) +} + +// cleanupTestData removes all test data +func cleanupTestData() { + if testDB == nil { + return + } + testDB.Exec("DELETE FROM task_taskcompletion WHERE task_id IN (SELECT id FROM task_task WHERE title LIKE 'test_%')") + testDB.Exec("DELETE FROM task_task WHERE title LIKE 'test_%'") + testDB.Exec("DELETE FROM task_taskstatus WHERE name LIKE 'test_%'") + testDB.Exec("DELETE FROM residence_residence WHERE name LIKE 'test_%'") +} + +// Helper to create a time pointer +func timePtr(t time.Time) *time.Time { + return &t +} + +// testUserID is a user ID that exists in the database for foreign key constraints +var testUserID uint = 1 + +// createTestResidence creates a test residence and returns its ID +func createTestResidence(t *testing.T) uint { + residence := &models.Residence{ + Name: "test_residence_" + time.Now().Format("20060102150405"), + OwnerID: testUserID, + IsActive: true, + } + if err := testDB.Create(residence).Error; err != nil { + t.Fatalf("Failed to create test residence: %v", err) + } + return residence.ID +} + +// createTestStatus creates a test status and returns it +func createTestStatus(t *testing.T, name string) *models.TaskStatus { + status := &models.TaskStatus{ + Name: "test_" + name, + } + if err := testDB.Create(status).Error; err != nil { + t.Fatalf("Failed to create test status: %v", err) + } + return status +} + +// createTestTask creates a task with the given properties +func createTestTask(t *testing.T, residenceID uint, task *models.Task) *models.Task { + task.ResidenceID = residenceID + task.Title = "test_" + task.Title + task.CreatedByID = testUserID // Required foreign key + if err := testDB.Create(task).Error; err != nil { + t.Fatalf("Failed to create test task: %v", err) + } + return task +} + +// createTestCompletion creates a completion for a task +func createTestCompletion(t *testing.T, taskID uint) *models.TaskCompletion { + completion := &models.TaskCompletion{ + TaskID: taskID, + CompletedByID: testUserID, // Required foreign key + CompletedAt: time.Now().UTC(), + } + if err := testDB.Create(completion).Error; err != nil { + t.Fatalf("Failed to create test completion: %v", err) + } + return completion +} + +// TestScopeActiveMatchesPredicate verifies ScopeActive produces same results as IsActive +func TestScopeActiveMatchesPredicate(t *testing.T) { + if testDB == nil { + t.Skip("Database not available") + } + + residenceID := createTestResidence(t) + defer cleanupTestData() + + // Create tasks with different active states + tasks := []*models.Task{ + {Title: "active_task", IsCancelled: false, IsArchived: false}, + {Title: "cancelled_task", IsCancelled: true, IsArchived: false}, + {Title: "archived_task", IsCancelled: false, IsArchived: true}, + {Title: "both_task", IsCancelled: true, IsArchived: true}, + } + + for _, task := range tasks { + createTestTask(t, residenceID, task) + } + + // Query using scope + var scopeResults []models.Task + err := testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeActive). + Find(&scopeResults).Error + if err != nil { + t.Fatalf("Scope query failed: %v", err) + } + + // Query all tasks and filter with predicate + var allTasks []models.Task + testDB.Where("residence_id = ?", residenceID).Find(&allTasks) + + var predicateResults []models.Task + for _, task := range allTasks { + if predicates.IsActive(&task) { + predicateResults = append(predicateResults, task) + } + } + + // Compare results + if len(scopeResults) != len(predicateResults) { + t.Errorf("ScopeActive returned %d tasks, IsActive predicate returned %d tasks", + len(scopeResults), len(predicateResults)) + } + + // Should only have the active task + if len(scopeResults) != 1 { + t.Errorf("Expected 1 active task, got %d", len(scopeResults)) + } +} + +// TestScopeCompletedMatchesPredicate verifies ScopeCompleted produces same results as IsCompleted +func TestScopeCompletedMatchesPredicate(t *testing.T) { + if testDB == nil { + t.Skip("Database not available") + } + + residenceID := createTestResidence(t) + defer cleanupTestData() + + now := time.Now().UTC() + nextWeek := now.AddDate(0, 0, 7) + + // Create tasks with different completion states + // Completed: NextDueDate nil AND has completions + completedTask := createTestTask(t, residenceID, &models.Task{ + Title: "completed_task", + NextDueDate: nil, + IsCancelled: false, + IsArchived: false, + }) + createTestCompletion(t, completedTask.ID) + + // Not completed: has completions but NextDueDate set (recurring) + recurringTask := createTestTask(t, residenceID, &models.Task{ + Title: "recurring_with_completion", + NextDueDate: timePtr(nextWeek), + IsCancelled: false, + IsArchived: false, + }) + createTestCompletion(t, recurringTask.ID) + + // Not completed: NextDueDate nil but no completions + createTestTask(t, residenceID, &models.Task{ + Title: "no_completions", + NextDueDate: nil, + IsCancelled: false, + IsArchived: false, + }) + + // Not completed: has NextDueDate, no completions + createTestTask(t, residenceID, &models.Task{ + Title: "pending_task", + NextDueDate: timePtr(nextWeek), + IsCancelled: false, + IsArchived: false, + }) + + // Query using scope + var scopeResults []models.Task + err := testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeCompleted). + Find(&scopeResults).Error + if err != nil { + t.Fatalf("Scope query failed: %v", err) + } + + // Query all tasks with completions preloaded and filter with predicate + var allTasks []models.Task + testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks) + + var predicateResults []models.Task + for _, task := range allTasks { + if predicates.IsCompleted(&task) { + predicateResults = append(predicateResults, task) + } + } + + // Compare results + if len(scopeResults) != len(predicateResults) { + t.Errorf("ScopeCompleted returned %d tasks, IsCompleted predicate returned %d tasks", + len(scopeResults), len(predicateResults)) + } + + // Should only have the completed task (nil NextDueDate + has completion) + if len(scopeResults) != 1 { + t.Errorf("Expected 1 completed task, got %d", len(scopeResults)) + } +} + +// TestScopeOverdueMatchesPredicate verifies ScopeOverdue produces same results as IsOverdue +func TestScopeOverdueMatchesPredicate(t *testing.T) { + if testDB == nil { + t.Skip("Database not available") + } + + residenceID := createTestResidence(t) + defer cleanupTestData() + + now := time.Now().UTC() + yesterday := now.AddDate(0, 0, -1) + tomorrow := now.AddDate(0, 0, 1) + + // Overdue: NextDueDate in past, active, not completed + createTestTask(t, residenceID, &models.Task{ + Title: "overdue_task", + NextDueDate: timePtr(yesterday), + IsCancelled: false, + IsArchived: false, + }) + + // Overdue: DueDate in past (NextDueDate nil, no completions) + createTestTask(t, residenceID, &models.Task{ + Title: "overdue_duedate", + NextDueDate: nil, + DueDate: timePtr(yesterday), + IsCancelled: false, + IsArchived: false, + }) + + // Not overdue: future date + createTestTask(t, residenceID, &models.Task{ + Title: "future_task", + NextDueDate: timePtr(tomorrow), + IsCancelled: false, + IsArchived: false, + }) + + // Not overdue: cancelled + createTestTask(t, residenceID, &models.Task{ + Title: "cancelled_overdue", + NextDueDate: timePtr(yesterday), + IsCancelled: true, + IsArchived: false, + }) + + // Not overdue: completed (NextDueDate nil with completion) + completedTask := createTestTask(t, residenceID, &models.Task{ + Title: "completed_past_due", + NextDueDate: nil, + DueDate: timePtr(yesterday), + IsCancelled: false, + IsArchived: false, + }) + createTestCompletion(t, completedTask.ID) + + // Not overdue: no due date + createTestTask(t, residenceID, &models.Task{ + Title: "no_due_date", + NextDueDate: nil, + DueDate: nil, + IsCancelled: false, + IsArchived: false, + }) + + // Query using scope + var scopeResults []models.Task + err := testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)). + Find(&scopeResults).Error + if err != nil { + t.Fatalf("Scope query failed: %v", err) + } + + // Query all tasks with completions preloaded and filter with predicate + var allTasks []models.Task + testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks) + + var predicateResults []models.Task + for _, task := range allTasks { + if predicates.IsOverdue(&task, now) { + predicateResults = append(predicateResults, task) + } + } + + // Compare results + if len(scopeResults) != len(predicateResults) { + t.Errorf("ScopeOverdue returned %d tasks, IsOverdue predicate returned %d tasks", + len(scopeResults), len(predicateResults)) + t.Logf("Scope results: %v", getTaskTitles(scopeResults)) + t.Logf("Predicate results: %v", getTaskTitles(predicateResults)) + } + + // Should have 2 overdue tasks + if len(scopeResults) != 2 { + t.Errorf("Expected 2 overdue tasks, got %d", len(scopeResults)) + } +} + +// TestScopeOverdueWithSameDayTask tests the DATE vs TIMESTAMP comparison edge case +// This is a regression test for the bug where tasks due "today" were not counted as overdue +func TestScopeOverdueWithSameDayTask(t *testing.T) { + if testDB == nil { + t.Skip("Database not available") + } + + residenceID := createTestResidence(t) + defer cleanupTestData() + + // Create a task due at midnight today (simulating a DATE column) + now := time.Now().UTC() + todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + + createTestTask(t, residenceID, &models.Task{ + Title: "due_today_midnight", + NextDueDate: timePtr(todayMidnight), + IsCancelled: false, + IsArchived: false, + }) + + // Query using scope with current time (after midnight) + var scopeResults []models.Task + err := testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)). + Find(&scopeResults).Error + if err != nil { + t.Fatalf("Scope query failed: %v", err) + } + + // Query with predicate + var allTasks []models.Task + testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks) + + var predicateResults []models.Task + for _, task := range allTasks { + if predicates.IsOverdue(&task, now) { + predicateResults = append(predicateResults, task) + } + } + + // Both should agree: if it's past midnight, the task due at midnight is overdue + if len(scopeResults) != len(predicateResults) { + t.Errorf("DATE vs TIMESTAMP mismatch! Scope returned %d, predicate returned %d", + len(scopeResults), len(predicateResults)) + t.Logf("This indicates the PostgreSQL DATE/TIMESTAMP comparison bug may have returned") + } + + // If current time is after midnight, task should be overdue + if now.After(todayMidnight) && len(scopeResults) != 1 { + t.Errorf("Task due at midnight should be overdue after midnight, got %d results", len(scopeResults)) + } +} + +// TestScopeDueSoonMatchesPredicate verifies ScopeDueSoon produces same results as IsDueSoon +func TestScopeDueSoonMatchesPredicate(t *testing.T) { + if testDB == nil { + t.Skip("Database not available") + } + + residenceID := createTestResidence(t) + defer cleanupTestData() + + now := time.Now().UTC() + yesterday := now.AddDate(0, 0, -1) + in5Days := now.AddDate(0, 0, 5) + in60Days := now.AddDate(0, 0, 60) + daysThreshold := 30 + + // Due soon: within threshold + createTestTask(t, residenceID, &models.Task{ + Title: "due_soon", + NextDueDate: timePtr(in5Days), + IsCancelled: false, + IsArchived: false, + }) + + // Not due soon: beyond threshold + createTestTask(t, residenceID, &models.Task{ + Title: "far_future", + NextDueDate: timePtr(in60Days), + IsCancelled: false, + IsArchived: false, + }) + + // Not due soon: overdue (in past) + createTestTask(t, residenceID, &models.Task{ + Title: "overdue", + NextDueDate: timePtr(yesterday), + IsCancelled: false, + IsArchived: false, + }) + + // Not due soon: cancelled + createTestTask(t, residenceID, &models.Task{ + Title: "cancelled", + NextDueDate: timePtr(in5Days), + IsCancelled: true, + IsArchived: false, + }) + + // Query using scope + var scopeResults []models.Task + err := testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeDueSoon(now, daysThreshold)). + Find(&scopeResults).Error + if err != nil { + t.Fatalf("Scope query failed: %v", err) + } + + // Query all tasks and filter with predicate + var allTasks []models.Task + testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks) + + var predicateResults []models.Task + for _, task := range allTasks { + if predicates.IsDueSoon(&task, now, daysThreshold) { + predicateResults = append(predicateResults, task) + } + } + + // Compare results + if len(scopeResults) != len(predicateResults) { + t.Errorf("ScopeDueSoon returned %d tasks, IsDueSoon predicate returned %d tasks", + len(scopeResults), len(predicateResults)) + } + + // Should have 1 due soon task + if len(scopeResults) != 1 { + t.Errorf("Expected 1 due soon task, got %d", len(scopeResults)) + } +} + +// TestScopeUpcomingMatchesPredicate verifies ScopeUpcoming produces same results as IsUpcoming +func TestScopeUpcomingMatchesPredicate(t *testing.T) { + if testDB == nil { + t.Skip("Database not available") + } + + residenceID := createTestResidence(t) + defer cleanupTestData() + + now := time.Now().UTC() + in5Days := now.AddDate(0, 0, 5) + in60Days := now.AddDate(0, 0, 60) + daysThreshold := 30 + + // Upcoming: beyond threshold + createTestTask(t, residenceID, &models.Task{ + Title: "far_future", + NextDueDate: timePtr(in60Days), + IsCancelled: false, + IsArchived: false, + }) + + // Upcoming: no due date + createTestTask(t, residenceID, &models.Task{ + Title: "no_due_date", + NextDueDate: nil, + DueDate: nil, + IsCancelled: false, + IsArchived: false, + }) + + // Not upcoming: within due soon threshold + createTestTask(t, residenceID, &models.Task{ + Title: "due_soon", + NextDueDate: timePtr(in5Days), + IsCancelled: false, + IsArchived: false, + }) + + // Not upcoming: cancelled + createTestTask(t, residenceID, &models.Task{ + Title: "cancelled", + NextDueDate: timePtr(in60Days), + IsCancelled: true, + IsArchived: false, + }) + + // Query using scope + var scopeResults []models.Task + err := testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeUpcoming(now, daysThreshold)). + Find(&scopeResults).Error + if err != nil { + t.Fatalf("Scope query failed: %v", err) + } + + // Query all tasks and filter with predicate + var allTasks []models.Task + testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks) + + var predicateResults []models.Task + for _, task := range allTasks { + if predicates.IsUpcoming(&task, now, daysThreshold) { + predicateResults = append(predicateResults, task) + } + } + + // Compare results + if len(scopeResults) != len(predicateResults) { + t.Errorf("ScopeUpcoming returned %d tasks, IsUpcoming predicate returned %d tasks", + len(scopeResults), len(predicateResults)) + } + + // Should have 2 upcoming tasks + if len(scopeResults) != 2 { + t.Errorf("Expected 2 upcoming tasks, got %d", len(scopeResults)) + } +} + +// TestScopeInProgressMatchesPredicate verifies ScopeInProgress produces same results as IsInProgress +func TestScopeInProgressMatchesPredicate(t *testing.T) { + if testDB == nil { + t.Skip("Database not available") + } + + residenceID := createTestResidence(t) + + // For InProgress, we need to use the exact status name "In Progress" because + // the scope joins on task_taskstatus.name = 'In Progress' + // First, try to find existing "In Progress" status, or create one + var inProgressStatus models.TaskStatus + if err := testDB.Where("name = ?", "In Progress").First(&inProgressStatus).Error; err != nil { + // Create it if it doesn't exist + inProgressStatus = models.TaskStatus{Name: "In Progress"} + testDB.Create(&inProgressStatus) + } + + var pendingStatus models.TaskStatus + if err := testDB.Where("name = ?", "Pending").First(&pendingStatus).Error; err != nil { + pendingStatus = models.TaskStatus{Name: "Pending"} + testDB.Create(&pendingStatus) + } + + defer cleanupTestData() + + // In progress task + createTestTask(t, residenceID, &models.Task{ + Title: "in_progress", + StatusID: &inProgressStatus.ID, + }) + + // Not in progress: different status + createTestTask(t, residenceID, &models.Task{ + Title: "pending", + StatusID: &pendingStatus.ID, + }) + + // Not in progress: no status + createTestTask(t, residenceID, &models.Task{ + Title: "no_status", + StatusID: nil, + }) + + // Query using scope + var scopeResults []models.Task + err := testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeInProgress). + Find(&scopeResults).Error + if err != nil { + t.Fatalf("Scope query failed: %v", err) + } + + // Query all tasks with status preloaded and filter with predicate + var allTasks []models.Task + testDB.Preload("Status").Where("residence_id = ?", residenceID).Find(&allTasks) + + var predicateResults []models.Task + for _, task := range allTasks { + if predicates.IsInProgress(&task) { + predicateResults = append(predicateResults, task) + } + } + + // Compare results + if len(scopeResults) != len(predicateResults) { + t.Errorf("ScopeInProgress returned %d tasks, IsInProgress predicate returned %d tasks", + len(scopeResults), len(predicateResults)) + } + + // Should have 1 in progress task + if len(scopeResults) != 1 { + t.Errorf("Expected 1 in progress task, got %d", len(scopeResults)) + } +} + +// TestScopeForResidences verifies filtering by multiple residence IDs +func TestScopeForResidences(t *testing.T) { + if testDB == nil { + t.Skip("Database not available") + } + + residenceID1 := createTestResidence(t) + residenceID2 := createTestResidence(t) + residenceID3 := createTestResidence(t) + defer cleanupTestData() + + // Create tasks in different residences + createTestTask(t, residenceID1, &models.Task{Title: "task_r1"}) + createTestTask(t, residenceID2, &models.Task{Title: "task_r2"}) + createTestTask(t, residenceID3, &models.Task{Title: "task_r3"}) + + // Query for residences 1 and 2 only + var results []models.Task + err := testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidences([]uint{residenceID1, residenceID2})). + Find(&results).Error + if err != nil { + t.Fatalf("Scope query failed: %v", err) + } + + if len(results) != 2 { + t.Errorf("Expected 2 tasks from residences 1 and 2, got %d", len(results)) + } + + // Verify empty slice returns no results + var emptyResults []models.Task + testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidences([]uint{})). + Find(&emptyResults) + + if len(emptyResults) != 0 { + t.Errorf("Expected 0 tasks for empty residence list, got %d", len(emptyResults)) + } +} + +// Helper to get task titles for debugging +func getTaskTitles(tasks []models.Task) []string { + titles := make([]string, len(tasks)) + for i, task := range tasks { + titles[i] = task.Title + } + return titles +} diff --git a/internal/task/task.go b/internal/task/task.go new file mode 100644 index 0000000..cfccd61 --- /dev/null +++ b/internal/task/task.go @@ -0,0 +1,259 @@ +// Package task provides consolidated task domain logic. +// +// This package serves as the single entry point for all task-related business logic. +// It re-exports functions from sub-packages for convenient imports. +// +// Architecture: +// +// predicates/ - Pure Go predicate functions (SINGLE SOURCE OF TRUTH) +// scopes/ - GORM scope functions (SQL mirrors of predicates) +// categorization/ - Chain of Responsibility for kanban categorization +// +// Usage: +// +// import "github.com/treytartt/casera-api/internal/task" +// +// // Use predicates for in-memory checks +// if task.IsCompleted(myTask) { ... } +// +// // Use scopes for database queries +// db.Scopes(task.ScopeOverdue(now)).Find(&tasks) +// +// // Use categorization for kanban column determination +// column := task.CategorizeTask(myTask, 30) +// +// For more details, see docs/TASK_LOGIC_ARCHITECTURE.md +package task + +import ( + "time" + + "github.com/treytartt/casera-api/internal/models" + "github.com/treytartt/casera-api/internal/task/categorization" + "github.com/treytartt/casera-api/internal/task/predicates" + "github.com/treytartt/casera-api/internal/task/scopes" + "gorm.io/gorm" +) + +// ============================================================================= +// RE-EXPORTED TYPES +// ============================================================================= + +// KanbanColumn represents the possible kanban column names +type KanbanColumn = categorization.KanbanColumn + +// Column constants +const ( + ColumnOverdue = categorization.ColumnOverdue + ColumnDueSoon = categorization.ColumnDueSoon + ColumnUpcoming = categorization.ColumnUpcoming + ColumnInProgress = categorization.ColumnInProgress + ColumnCompleted = categorization.ColumnCompleted + ColumnCancelled = categorization.ColumnCancelled +) + +// ============================================================================= +// RE-EXPORTED PREDICATES +// These are the SINGLE SOURCE OF TRUTH for task logic +// ============================================================================= + +// IsCompleted returns true if a task is considered "completed" per kanban rules. +// A task is completed when NextDueDate is nil AND has at least one completion. +func IsCompleted(task *models.Task) bool { + return predicates.IsCompleted(task) +} + +// IsActive returns true if the task is not cancelled and not archived. +func IsActive(task *models.Task) bool { + return predicates.IsActive(task) +} + +// IsCancelled returns true if the task has been cancelled. +func IsCancelled(task *models.Task) bool { + return predicates.IsCancelled(task) +} + +// IsArchived returns true if the task has been archived. +func IsArchived(task *models.Task) bool { + return predicates.IsArchived(task) +} + +// IsInProgress returns true if the task has status "In Progress". +func IsInProgress(task *models.Task) bool { + return predicates.IsInProgress(task) +} + +// EffectiveDate returns the date used for scheduling calculations. +// Prefers NextDueDate, falls back to DueDate. +func EffectiveDate(task *models.Task) *time.Time { + return predicates.EffectiveDate(task) +} + +// IsOverdue returns true if the task's effective date is in the past. +func IsOverdue(task *models.Task, now time.Time) bool { + return predicates.IsOverdue(task, now) +} + +// IsDueSoon returns true if the task's effective date is within the threshold. +func IsDueSoon(task *models.Task, now time.Time, daysThreshold int) bool { + return predicates.IsDueSoon(task, now, daysThreshold) +} + +// IsUpcoming returns true if the task is due after the threshold or has no due date. +func IsUpcoming(task *models.Task, now time.Time, daysThreshold int) bool { + return predicates.IsUpcoming(task, now, daysThreshold) +} + +// HasCompletions returns true if the task has at least one completion record. +func HasCompletions(task *models.Task) bool { + return predicates.HasCompletions(task) +} + +// CompletionCount returns the number of completions for a task. +func CompletionCount(task *models.Task) int { + return predicates.CompletionCount(task) +} + +// IsRecurring returns true if the task has a recurring frequency. +func IsRecurring(task *models.Task) bool { + return predicates.IsRecurring(task) +} + +// IsOneTime returns true if the task is a one-time (non-recurring) task. +func IsOneTime(task *models.Task) bool { + return predicates.IsOneTime(task) +} + +// ============================================================================= +// RE-EXPORTED SCOPES +// These are SQL mirrors of the predicates for database queries +// ============================================================================= + +// ScopeActive filters to tasks that are not cancelled and not archived. +func ScopeActive(db *gorm.DB) *gorm.DB { + return scopes.ScopeActive(db) +} + +// ScopeCancelled filters to cancelled tasks only. +func ScopeCancelled(db *gorm.DB) *gorm.DB { + return scopes.ScopeCancelled(db) +} + +// ScopeArchived filters to archived tasks only. +func ScopeArchived(db *gorm.DB) *gorm.DB { + return scopes.ScopeArchived(db) +} + +// ScopeCompleted filters to completed tasks. +func ScopeCompleted(db *gorm.DB) *gorm.DB { + return scopes.ScopeCompleted(db) +} + +// ScopeNotCompleted excludes completed tasks. +func ScopeNotCompleted(db *gorm.DB) *gorm.DB { + return scopes.ScopeNotCompleted(db) +} + +// ScopeInProgress filters to tasks with status "In Progress". +func ScopeInProgress(db *gorm.DB) *gorm.DB { + return scopes.ScopeInProgress(db) +} + +// ScopeNotInProgress excludes tasks with status "In Progress". +func ScopeNotInProgress(db *gorm.DB) *gorm.DB { + return scopes.ScopeNotInProgress(db) +} + +// ScopeOverdue returns a scope for overdue tasks. +func ScopeOverdue(now time.Time) func(db *gorm.DB) *gorm.DB { + return scopes.ScopeOverdue(now) +} + +// ScopeDueSoon returns a scope for tasks due within the threshold. +func ScopeDueSoon(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB { + return scopes.ScopeDueSoon(now, daysThreshold) +} + +// ScopeUpcoming returns a scope for tasks due after the threshold or with no due date. +func ScopeUpcoming(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB { + return scopes.ScopeUpcoming(now, daysThreshold) +} + +// ScopeDueInRange returns a scope for tasks with effective date in a range. +func ScopeDueInRange(start, end time.Time) func(db *gorm.DB) *gorm.DB { + return scopes.ScopeDueInRange(start, end) +} + +// ScopeHasDueDate filters to tasks that have an effective due date. +func ScopeHasDueDate(db *gorm.DB) *gorm.DB { + return scopes.ScopeHasDueDate(db) +} + +// ScopeNoDueDate filters to tasks that have no effective due date. +func ScopeNoDueDate(db *gorm.DB) *gorm.DB { + return scopes.ScopeNoDueDate(db) +} + +// ScopeForResidence filters tasks by a single residence ID. +func ScopeForResidence(residenceID uint) func(db *gorm.DB) *gorm.DB { + return scopes.ScopeForResidence(residenceID) +} + +// ScopeForResidences filters tasks by multiple residence IDs. +func ScopeForResidences(residenceIDs []uint) func(db *gorm.DB) *gorm.DB { + return scopes.ScopeForResidences(residenceIDs) +} + +// ScopeHasCompletions filters to tasks that have at least one completion. +func ScopeHasCompletions(db *gorm.DB) *gorm.DB { + return scopes.ScopeHasCompletions(db) +} + +// ScopeNoCompletions filters to tasks that have no completions. +func ScopeNoCompletions(db *gorm.DB) *gorm.DB { + return scopes.ScopeNoCompletions(db) +} + +// ScopeOrderByDueDate orders tasks by effective due date ascending, nulls last. +func ScopeOrderByDueDate(db *gorm.DB) *gorm.DB { + return scopes.ScopeOrderByDueDate(db) +} + +// ScopeOrderByPriority orders tasks by priority level descending (urgent first). +func ScopeOrderByPriority(db *gorm.DB) *gorm.DB { + return scopes.ScopeOrderByPriority(db) +} + +// ScopeOrderByCreatedAt orders tasks by creation date descending (newest first). +func ScopeOrderByCreatedAt(db *gorm.DB) *gorm.DB { + return scopes.ScopeOrderByCreatedAt(db) +} + +// ScopeKanbanOrder applies the standard kanban ordering. +func ScopeKanbanOrder(db *gorm.DB) *gorm.DB { + return scopes.ScopeKanbanOrder(db) +} + +// ============================================================================= +// RE-EXPORTED CATEGORIZATION +// ============================================================================= + +// CategorizeTask determines which kanban column a task belongs to. +func CategorizeTask(task *models.Task, daysThreshold int) KanbanColumn { + return categorization.CategorizeTask(task, daysThreshold) +} + +// DetermineKanbanColumn is a convenience function that returns the column as a string. +func DetermineKanbanColumn(task *models.Task, daysThreshold int) string { + return categorization.DetermineKanbanColumn(task, daysThreshold) +} + +// CategorizeTasksIntoColumns categorizes multiple tasks into their respective columns. +func CategorizeTasksIntoColumns(tasks []models.Task, daysThreshold int) map[KanbanColumn][]models.Task { + return categorization.CategorizeTasksIntoColumns(tasks, daysThreshold) +} + +// NewChain creates a new categorization chain for custom usage. +func NewChain() *categorization.Chain { + return categorization.NewChain() +} diff --git a/internal/worker/jobs/handler.go b/internal/worker/jobs/handler.go index 8f61550..23efd14 100644 --- a/internal/worker/jobs/handler.go +++ b/internal/worker/jobs/handler.go @@ -101,16 +101,16 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro log.Info().Int("eligible_users", len(eligibleUserIDs)).Msg("Found users eligible for task reminders this hour") // Step 2: Query tasks due today or tomorrow only for eligible users - // A task is considered "completed" (and should be excluded) if: - // - NextDueDate IS NULL AND it has at least one completion record - // This matches the kanban categorization logic + // Completion detection logic matches internal/task/predicates.IsCompleted: + // A task is "completed" when NextDueDate == nil AND has at least one completion. + // See internal/task/scopes.ScopeNotCompleted for the SQL equivalent. var dueSoonTasks []models.Task err = h.db.Preload("Status").Preload("Completions").Preload("Residence"). Where("(due_date >= ? AND due_date < ?) OR (next_due_date >= ? AND next_due_date < ?)", today, dayAfterTomorrow, today, dayAfterTomorrow). Where("is_cancelled = false"). Where("is_archived = false"). - // Exclude completed tasks: tasks with no next_due_date AND at least one completion + // Exclude completed tasks (matches scopes.ScopeNotCompleted) Where("NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))"). Where("(assigned_to_id IN ? OR residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ?))", eligibleUserIDs, eligibleUserIDs). @@ -218,15 +218,15 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e log.Info().Int("eligible_users", len(eligibleUserIDs)).Msg("Found users eligible for overdue reminders this hour") // Step 2: Query overdue tasks only for eligible users - // A task is considered "completed" (and should be excluded) if: - // - NextDueDate IS NULL AND it has at least one completion record - // This matches the kanban categorization logic + // Completion detection logic matches internal/task/predicates.IsCompleted: + // A task is "completed" when NextDueDate == nil AND has at least one completion. + // See internal/task/scopes.ScopeNotCompleted for the SQL equivalent. var overdueTasks []models.Task err = h.db.Preload("Status").Preload("Completions").Preload("Residence"). Where("due_date < ? OR next_due_date < ?", today, today). Where("is_cancelled = false"). Where("is_archived = false"). - // Exclude completed tasks: tasks with no next_due_date AND at least one completion + // Exclude completed tasks (matches scopes.ScopeNotCompleted) Where("NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))"). Where("(assigned_to_id IN ? OR residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ?))", eligibleUserIDs, eligibleUserIDs). @@ -299,6 +299,9 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error nextWeek := today.AddDate(0, 0, 7) // Get all users with their task statistics + // Completion detection logic matches internal/task/predicates.IsCompleted: + // A task is "completed" when NextDueDate == nil AND has at least one completion. + // We use COALESCE(next_due_date, due_date) as the effective date for categorization. var userStats []struct { UserID uint TotalTasks int @@ -310,8 +313,16 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error SELECT u.id as user_id, COUNT(DISTINCT t.id) as total_tasks, - COUNT(DISTINCT CASE WHEN t.due_date < ? AND tc.id IS NULL THEN t.id END) as overdue_tasks, - COUNT(DISTINCT CASE WHEN t.due_date >= ? AND t.due_date < ? AND tc.id IS NULL THEN t.id END) as due_this_week + COUNT(DISTINCT CASE + WHEN COALESCE(t.next_due_date, t.due_date) < ? + AND NOT (t.next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = t.id)) + THEN t.id + END) as overdue_tasks, + COUNT(DISTINCT CASE + WHEN COALESCE(t.next_due_date, t.due_date) >= ? AND COALESCE(t.next_due_date, t.due_date) < ? + AND NOT (t.next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = t.id)) + THEN t.id + END) as due_this_week FROM auth_user u JOIN residence_residence r ON r.owner_id = u.id OR r.id IN ( SELECT residence_id FROM residence_residence_users WHERE user_id = u.id @@ -319,7 +330,6 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error JOIN task_task t ON t.residence_id = r.id AND t.is_cancelled = false AND t.is_archived = false - LEFT JOIN task_taskcompletion tc ON t.id = tc.task_id WHERE u.is_active = true GROUP BY u.id HAVING COUNT(DISTINCT t.id) > 0