This refactor eliminates duplicate task logic across the codebase by creating a centralized task package with three layers: - predicates/: Pure Go functions defining task state logic (IsCompleted, IsOverdue, IsDueSoon, IsUpcoming, IsActive, IsInProgress, EffectiveDate) - scopes/: GORM scope functions mirroring predicates for database queries - categorization/: Chain of Responsibility pattern for kanban column assignment Key fixes: - Fixed PostgreSQL DATE vs TIMESTAMP comparison bug in scopes (added explicit ::timestamp casts) that caused summary/kanban count mismatches - Fixed models/task.go IsOverdue() and IsDueSoon() to use EffectiveDate (NextDueDate ?? DueDate) instead of only DueDate - Removed duplicate isTaskCompleted() helpers from task_repo.go and task_button_types.go Files refactored to use consolidated logic: - task_repo.go: Uses scopes for statistics, predicates for filtering - task_button_types.go: Uses predicates instead of inline logic - responses/task.go: Delegates to categorization package - dashboard_handler.go: Uses scopes for task statistics - residence_service.go: Uses predicates for report generation - worker/jobs/handler.go: Documented SQL with predicate references Added comprehensive tests: - predicates_test.go: Unit tests for all predicate functions - scopes_test.go: Integration tests verifying scopes match predicates - consistency_test.go: Three-layer consistency tests ensuring predicates, scopes, and categorization all return identical results 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
12 KiB
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.
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.
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.
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:
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:
- Cancelled (highest) -
IsCancelled == true - Completed -
NextDueDate == nil && len(Completions) > 0 - In Progress -
Status.Name == "In Progress" - Overdue -
EffectiveDate < now - Due Soon -
now <= EffectiveDate < threshold - Upcoming (lowest/default) - Everything else
Usage Examples
Counting Overdue Tasks (Efficient)
// 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
// 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
// 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
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
- Scopes mirror predicates - Each scope produces the same logical result as its predicate counterpart
- Tests verify consistency - Unit tests ensure predicates and scopes produce identical results
- Cross-reference comments - Each scope has a comment linking to its predicate equivalent
- Single import point - The
taskpackage 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)
// DON'T: Inline logic that may drift
if task.NextDueDate == nil && len(task.Completions) > 0 {
// completed...
}
New Pattern (Preferred)
// 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:
// 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:
- Add the predicate first in
predicates/predicates.go - Add the corresponding scope in
scopes/scopes.go(if DB queries needed) - Update the categorization chain if it affects kanban columns
- Add tests for both predicate and scope
- 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:
-- 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:
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:
// 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:
// 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:
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:
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 logicdocs/TASK_KANBAN_LOGIC.md- Original kanban implementation notes