Consolidate task logic into single source of truth (DRY refactor)

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>
This commit is contained in:
Trey t
2025-12-07 11:48:03 -06:00
parent f0c7b070d7
commit cfb8a28870
16 changed files with 3408 additions and 679 deletions

View File

@@ -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