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

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

View File

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