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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user