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

@@ -4,12 +4,16 @@
// The chain evaluates tasks in a specific priority order, with each handler
// checking if the task matches its criteria. If a handler matches, it returns
// the column name; otherwise, it passes to the next handler in the chain.
//
// IMPORTANT: This package uses predicates from the parent task package as the
// single source of truth for task logic. Do NOT duplicate logic here.
package categorization
import (
"time"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/task/predicates"
)
// KanbanColumn represents the possible kanban column names
@@ -37,12 +41,12 @@ type Context struct {
}
// NewContext creates a new categorization context with sensible defaults
func NewContext(task *models.Task, daysThreshold int) *Context {
func NewContext(t *models.Task, daysThreshold int) *Context {
if daysThreshold <= 0 {
daysThreshold = 30
}
return &Context{
Task: task,
Task: t,
Now: time.Now().UTC(),
DaysThreshold: daysThreshold,
}
@@ -83,6 +87,7 @@ func (h *BaseHandler) HandleNext(ctx *Context) KanbanColumn {
}
// === Concrete Handlers ===
// Each handler uses predicates from the task package as the source of truth.
// CancelledHandler checks if the task is cancelled
// Priority: 1 (highest - checked first)
@@ -91,7 +96,8 @@ type CancelledHandler struct {
}
func (h *CancelledHandler) Handle(ctx *Context) KanbanColumn {
if ctx.Task.IsCancelled {
// Uses predicate: predicates.IsCancelled
if predicates.IsCancelled(ctx.Task) {
return ColumnCancelled
}
return h.HandleNext(ctx)
@@ -104,10 +110,9 @@ type CompletedHandler struct {
}
func (h *CompletedHandler) Handle(ctx *Context) KanbanColumn {
// A task is completed if:
// - It has at least one completion record
// - AND it has no NextDueDate (meaning it's a one-time task or the cycle is done)
if ctx.Task.NextDueDate == nil && len(ctx.Task.Completions) > 0 {
// Uses predicate: predicates.IsCompleted
// A task is completed if NextDueDate is nil AND has at least one completion
if predicates.IsCompleted(ctx.Task) {
return ColumnCompleted
}
return h.HandleNext(ctx)
@@ -120,7 +125,8 @@ type InProgressHandler struct {
}
func (h *InProgressHandler) Handle(ctx *Context) KanbanColumn {
if ctx.Task.Status != nil && ctx.Task.Status.Name == "In Progress" {
// Uses predicate: predicates.IsInProgress
if predicates.IsInProgress(ctx.Task) {
return ColumnInProgress
}
return h.HandleNext(ctx)
@@ -133,22 +139,16 @@ type OverdueHandler struct {
}
func (h *OverdueHandler) Handle(ctx *Context) KanbanColumn {
effectiveDate := h.getEffectiveDate(ctx.Task)
// Uses predicate: predicates.EffectiveDate
// Note: We don't use predicates.IsOverdue here because the chain has already
// filtered out cancelled and completed tasks. We just need the date check.
effectiveDate := predicates.EffectiveDate(ctx.Task)
if effectiveDate != nil && effectiveDate.Before(ctx.Now) {
return ColumnOverdue
}
return h.HandleNext(ctx)
}
func (h *OverdueHandler) getEffectiveDate(task *models.Task) *time.Time {
// Prefer NextDueDate for recurring tasks
if task.NextDueDate != nil {
return task.NextDueDate
}
// Fall back to DueDate for initial categorization
return task.DueDate
}
// DueSoonHandler checks if the task is due within the threshold period
// Priority: 5
type DueSoonHandler struct {
@@ -156,7 +156,8 @@ type DueSoonHandler struct {
}
func (h *DueSoonHandler) Handle(ctx *Context) KanbanColumn {
effectiveDate := h.getEffectiveDate(ctx.Task)
// Uses predicate: predicates.EffectiveDate
effectiveDate := predicates.EffectiveDate(ctx.Task)
threshold := ctx.ThresholdDate()
if effectiveDate != nil && effectiveDate.Before(threshold) {
@@ -165,13 +166,6 @@ func (h *DueSoonHandler) Handle(ctx *Context) KanbanColumn {
return h.HandleNext(ctx)
}
func (h *DueSoonHandler) getEffectiveDate(task *models.Task) *time.Time {
if task.NextDueDate != nil {
return task.NextDueDate
}
return task.DueDate
}
// UpcomingHandler is the final handler that catches all remaining tasks
// Priority: 6 (lowest - default)
type UpcomingHandler struct {
@@ -179,7 +173,10 @@ type UpcomingHandler struct {
}
func (h *UpcomingHandler) Handle(ctx *Context) KanbanColumn {
// This is the default catch-all
// This is the default catch-all for tasks that:
// - Are not cancelled, completed, or in progress
// - Are not overdue or due soon
// - Have a due date far in the future OR no due date at all
return ColumnUpcoming
}
@@ -211,8 +208,8 @@ func NewChain() *Chain {
}
// Categorize determines which kanban column a task belongs to
func (c *Chain) Categorize(task *models.Task, daysThreshold int) KanbanColumn {
ctx := NewContext(task, daysThreshold)
func (c *Chain) Categorize(t *models.Task, daysThreshold int) KanbanColumn {
ctx := NewContext(t, daysThreshold)
return c.head.Handle(ctx)
}
@@ -227,13 +224,13 @@ func (c *Chain) CategorizeWithContext(ctx *Context) KanbanColumn {
var defaultChain = NewChain()
// DetermineKanbanColumn is a convenience function that uses the default chain
func DetermineKanbanColumn(task *models.Task, daysThreshold int) string {
return defaultChain.Categorize(task, daysThreshold).String()
func DetermineKanbanColumn(t *models.Task, daysThreshold int) string {
return defaultChain.Categorize(t, daysThreshold).String()
}
// CategorizeTask is an alias for DetermineKanbanColumn with a more descriptive name
func CategorizeTask(task *models.Task, daysThreshold int) KanbanColumn {
return defaultChain.Categorize(task, daysThreshold)
func CategorizeTask(t *models.Task, daysThreshold int) KanbanColumn {
return defaultChain.Categorize(t, daysThreshold)
}
// CategorizeTasksIntoColumns categorizes multiple tasks into their respective columns
@@ -250,9 +247,9 @@ func CategorizeTasksIntoColumns(tasks []models.Task, daysThreshold int) map[Kanb
// Categorize each task
chain := NewChain()
for _, task := range tasks {
column := chain.Categorize(&task, daysThreshold)
result[column] = append(result[column], task)
for _, t := range tasks {
column := chain.Categorize(&t, daysThreshold)
result[column] = append(result[column], t)
}
return result