# 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