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