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

@@ -0,0 +1,369 @@
# Task Logic Architecture
This document describes the consolidated task logic architecture following DRY principles. All task-related logic (completion detection, overdue calculation, kanban categorization, statistics counting) is centralized in the `internal/task/` package.
## Architecture Overview
```
internal/task/
├── predicates/
│ └── predicates.go # SINGLE SOURCE OF TRUTH - Pure Go predicate functions
├── scopes/
│ └── scopes.go # SQL mirrors of predicates for GORM queries
├── categorization/
│ └── chain.go # Chain of Responsibility for kanban column determination
└── task.go # Re-exports for convenient single import
```
## Package Responsibilities
### predicates/ - Single Source of Truth
Pure Go functions that define task logic. These are the **canonical definitions** for all task states.
```go
import "github.com/treytartt/casera-api/internal/task/predicates"
// State checks
predicates.IsCompleted(task) // NextDueDate == nil && len(Completions) > 0
predicates.IsActive(task) // !IsCancelled && !IsArchived
predicates.IsCancelled(task) // IsCancelled == true
predicates.IsArchived(task) // IsArchived == true
predicates.IsInProgress(task) // Status.Name == "In Progress"
// Date calculations
predicates.EffectiveDate(task) // NextDueDate ?? DueDate
predicates.IsOverdue(task, now) // Active, not completed, effectiveDate < now
predicates.IsDueSoon(task, now, days) // Active, not completed, now <= effectiveDate < threshold
predicates.IsUpcoming(task, now, days) // Everything else
```
### scopes/ - SQL Mirrors for Database Queries
GORM scope functions that produce the same results as predicates, but execute at the database level. Use these when counting or filtering large datasets without loading all records into memory.
```go
import "github.com/treytartt/casera-api/internal/task/scopes"
// State scopes
db.Scopes(scopes.ScopeActive) // is_cancelled = false AND is_archived = false
db.Scopes(scopes.ScopeCompleted) // next_due_date IS NULL AND EXISTS(completion)
db.Scopes(scopes.ScopeNotCompleted) // NOT (next_due_date IS NULL AND EXISTS(completion))
db.Scopes(scopes.ScopeInProgress) // JOIN status WHERE name = 'In Progress'
// Date scopes (require time parameter)
db.Scopes(scopes.ScopeOverdue(now)) // COALESCE(next_due_date, due_date) < now
db.Scopes(scopes.ScopeDueSoon(now, 30)) // >= now AND < threshold
db.Scopes(scopes.ScopeUpcoming(now, 30)) // >= threshold OR no due date
// Filter scopes
db.Scopes(scopes.ScopeForResidence(id)) // residence_id = ?
db.Scopes(scopes.ScopeForResidences(ids)) // residence_id IN (?)
// Ordering
db.Scopes(scopes.ScopeKanbanOrder) // Due date ASC, priority DESC, created DESC
```
### categorization/ - Chain of Responsibility Pattern
Determines which kanban column a task belongs to. Uses predicates internally.
```go
import "github.com/treytartt/casera-api/internal/task/categorization"
// Single task
column := categorization.CategorizeTask(task, 30)
columnStr := categorization.DetermineKanbanColumn(task, 30)
// Multiple tasks
columns := categorization.CategorizeTasksIntoColumns(tasks, 30)
// Returns map[KanbanColumn][]Task
```
### task.go - Convenient Re-exports
For most use cases, import the main task package which re-exports everything:
```go
import "github.com/treytartt/casera-api/internal/task"
// Use predicates
if task.IsCompleted(t) { ... }
// Use scopes
db.Scopes(task.ScopeOverdue(now)).Count(&count)
// Use categorization
column := task.CategorizeTask(t, 30)
```
## Canonical Rules (Single Source of Truth)
These rules are defined in `predicates/predicates.go` and enforced everywhere:
| Concept | Definition | SQL Equivalent |
|---------|------------|----------------|
| **Completed** | `NextDueDate == nil && len(Completions) > 0` | `next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion WHERE task_id = ?)` |
| **Active** | `!IsCancelled && !IsArchived` | `is_cancelled = false AND is_archived = false` |
| **In Progress** | `Status.Name == "In Progress"` | `JOIN task_taskstatus WHERE name = 'In Progress'` |
| **Effective Date** | `NextDueDate ?? DueDate` | `COALESCE(next_due_date, due_date)` |
| **Overdue** | `Active && !Completed && EffectiveDate < now` | Active + NotCompleted + `COALESCE(...) < ?` |
| **Due Soon** | `Active && !Completed && now <= EffectiveDate < threshold` | Active + NotCompleted + `COALESCE(...) >= ? AND COALESCE(...) < ?` |
| **Upcoming** | Everything else (future or no due date) | Active + NotCompleted + `COALESCE(...) >= ? OR (both NULL)` |
## Kanban Column Priority Order
When categorizing a task, the chain evaluates in this priority order:
1. **Cancelled** (highest) - `IsCancelled == true`
2. **Completed** - `NextDueDate == nil && len(Completions) > 0`
3. **In Progress** - `Status.Name == "In Progress"`
4. **Overdue** - `EffectiveDate < now`
5. **Due Soon** - `now <= EffectiveDate < threshold`
6. **Upcoming** (lowest/default) - Everything else
## Usage Examples
### Counting Overdue Tasks (Efficient)
```go
// Use scopes for database-level counting
var count int64
db.Model(&models.Task{}).
Scopes(task.ScopeForResidences(residenceIDs), task.ScopeOverdue(now)).
Count(&count)
```
### Building a Kanban Board
```go
// Load tasks with preloads
var tasks []models.Task
db.Preload("Status").Preload("Completions").
Scopes(task.ScopeForResidence(residenceID)).
Find(&tasks)
// Categorize in memory using predicates
columns := task.CategorizeTasksIntoColumns(tasks, 30)
```
### Checking Task State in Business Logic
```go
// Use predicates for in-memory checks
if task.IsCompleted(t) {
return []string{} // No actions for completed tasks
}
if task.IsOverdue(t, time.Now().UTC()) {
return []string{"edit", "complete", "cancel", "mark_in_progress"}
}
```
### Button Types for a Task
```go
func GetButtonTypesForTask(t *models.Task, daysThreshold int) []string {
now := time.Now().UTC()
if predicates.IsCancelled(t) {
return []string{"uncancel", "delete"}
}
if predicates.IsCompleted(t) {
return []string{} // read-only
}
if predicates.IsInProgress(t) {
return []string{"edit", "complete", "cancel"}
}
// Overdue, Due Soon, Upcoming all get the same buttons
return []string{"edit", "complete", "cancel", "mark_in_progress"}
}
```
## Why Two Layers (Predicates + Scopes)?
| Layer | Use Case | Performance |
|-------|----------|-------------|
| **Predicates** | In-memory checks on loaded objects | Fast for small sets, already loaded data |
| **Scopes** | Database-level filtering/counting | Efficient for large datasets, avoids loading all records |
**Example**: Counting overdue tasks across 1000 residences:
- Predicates: Load all tasks into memory, filter in Go
- Scopes: Execute `SELECT COUNT(*) WHERE ...` in database
## Consistency Guarantees
1. **Scopes mirror predicates** - Each scope produces the same logical result as its predicate counterpart
2. **Tests verify consistency** - Unit tests ensure predicates and scopes produce identical results
3. **Cross-reference comments** - Each scope has a comment linking to its predicate equivalent
4. **Single import point** - The `task` package re-exports everything, making it easy to use consistently
## Files That Were Refactored
The following files were updated to use the consolidated task logic:
| File | Changes |
|------|---------|
| `internal/repositories/task_repo.go` | Removed duplicate `isTaskCompleted()`, uses categorization and scopes |
| `internal/services/task_button_types.go` | Uses predicates instead of inline logic |
| `internal/dto/responses/task.go` | `DetermineKanbanColumn` delegates to categorization package |
| `internal/worker/jobs/handler.go` | SQL queries documented with predicate references |
| `internal/admin/handlers/dashboard_handler.go` | Uses scopes for task statistics |
| `internal/services/residence_service.go` | Uses predicates for report generation |
| `internal/models/task.go` | `IsOverdue()` and `IsDueSoon()` fixed to use EffectiveDate |
## Migration Notes
### Old Pattern (Avoid)
```go
// DON'T: Inline logic that may drift
if task.NextDueDate == nil && len(task.Completions) > 0 {
// completed...
}
```
### New Pattern (Preferred)
```go
// DO: Use predicates
if predicates.IsCompleted(task) {
// completed...
}
// Or via the task package
if task.IsCompleted(t) {
// completed...
}
```
## Developer Checklist: Adding Task-Related Features
**BEFORE writing any task-related code, ask yourself:**
### 1. Does this logic already exist?
Check `internal/task/predicates/predicates.go` for:
- [ ] State checks: `IsCompleted`, `IsActive`, `IsCancelled`, `IsArchived`, `IsInProgress`
- [ ] Date logic: `EffectiveDate`, `IsOverdue`, `IsDueSoon`, `IsUpcoming`
- [ ] Completion helpers: `HasCompletions`, `CompletionCount`, `IsRecurring`
### 2. Am I querying tasks from the database?
Use scopes from `internal/task/scopes/scopes.go`:
- [ ] Filtering by state: `ScopeActive`, `ScopeCompleted`, `ScopeNotCompleted`
- [ ] Filtering by date: `ScopeOverdue(now)`, `ScopeDueSoon(now, days)`, `ScopeUpcoming(now, days)`
- [ ] Filtering by residence: `ScopeForResidence(id)`, `ScopeForResidences(ids)`
- [ ] Ordering: `ScopeKanbanOrder`
### 3. Am I categorizing tasks into kanban columns?
Use `internal/task/categorization`:
- [ ] Single task: `categorization.CategorizeTask(task, daysThreshold)`
- [ ] Multiple tasks: `categorization.CategorizeTasksIntoColumns(tasks, daysThreshold)`
### 4. Am I writing inline task logic?
**STOP!** If you're writing any of these patterns inline, use the existing functions instead:
```go
// BAD: Inline completion check
if task.NextDueDate == nil && len(task.Completions) > 0 { ... }
// GOOD: Use predicate
if predicates.IsCompleted(task) { ... }
// BAD: Inline SQL for overdue
db.Where("due_date < ?", now)
// GOOD: Use scope
db.Scopes(scopes.ScopeOverdue(now))
// BAD: Manual kanban categorization
if task.IsCancelled { return "cancelled" }
else if ... { return "completed" }
// GOOD: Use categorization chain
column := categorization.CategorizeTask(task, 30)
```
### 5. Am I adding a NEW task state or concept?
If the existing predicates don't cover your use case:
1. **Add the predicate first** in `predicates/predicates.go`
2. **Add the corresponding scope** in `scopes/scopes.go` (if DB queries needed)
3. **Update the categorization chain** if it affects kanban columns
4. **Add tests** for both predicate and scope
5. **Update this documentation**
## Common Pitfalls
### PostgreSQL DATE vs TIMESTAMP Comparison
When comparing DATE columns with time parameters, GORM passes `time.Time` as a string. PostgreSQL then compares dates, not timestamps:
```sql
-- This compares DATES (wrong for time-of-day precision):
'2025-12-07'::date < '2025-12-07 17:00:00' -- FALSE!
-- This compares TIMESTAMPS (correct):
'2025-12-07'::date < '2025-12-07 17:00:00'::timestamp -- TRUE
```
**Solution**: All date scopes use explicit `::timestamp` casts:
```go
Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", now)
```
### Forgetting to Preload Completions
The `IsCompleted` predicate checks `len(task.Completions) > 0`. If you query tasks without preloading completions, this will always return false:
```go
// BAD: Completions not loaded
db.Find(&tasks)
predicates.IsCompleted(task) // Always false!
// GOOD: Preload completions
db.Preload("Completions").Find(&tasks)
predicates.IsCompleted(task) // Correct result
```
### Forgetting to Preload Status
The `IsInProgress` predicate checks `task.Status.Name == "In Progress"`. Without preloading:
```go
// BAD: Status not loaded
db.Find(&tasks)
predicates.IsInProgress(task) // Nil pointer or always false
// GOOD: Preload status
db.Preload("Status").Find(&tasks)
predicates.IsInProgress(task) // Correct result
```
## Quick Reference Import
For most files, use the convenience re-exports:
```go
import "github.com/treytartt/casera-api/internal/task"
// Then use:
task.IsCompleted(t)
task.ScopeOverdue(now)
task.CategorizeTask(t, 30)
```
For files that only need predicates or only need scopes:
```go
import "github.com/treytartt/casera-api/internal/task/predicates"
import "github.com/treytartt/casera-api/internal/task/scopes"
```
## Related Documentation
- `docs/TASK_KANBAN_CATEGORIZATION.md` - Detailed kanban column logic
- `docs/TASK_KANBAN_LOGIC.md` - Original kanban implementation notes

View File

@@ -8,6 +8,7 @@ import (
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/task/scopes"
)
// AdminDashboardHandler handles admin dashboard endpoints
@@ -115,43 +116,41 @@ func (h *AdminDashboardHandler) GetStats(c *gin.Context) {
h.db.Model(&models.Residence{}).Where("is_active = ?", true).Count(&stats.Residences.Active)
h.db.Model(&models.Residence{}).Where("created_at >= ?", thirtyDaysAgo).Count(&stats.Residences.New30d)
// Task stats
// Task stats - uses scopes from internal/task/scopes for consistency
h.db.Model(&models.Task{}).Count(&stats.Tasks.Total)
h.db.Model(&models.Task{}).Where("is_cancelled = ? AND is_archived = ?", false, false).Count(&stats.Tasks.Active)
h.db.Model(&models.Task{}).Where("is_archived = ?", true).Count(&stats.Tasks.Archived)
h.db.Model(&models.Task{}).Where("is_cancelled = ?", true).Count(&stats.Tasks.Cancelled)
h.db.Model(&models.Task{}).Scopes(scopes.ScopeActive).Count(&stats.Tasks.Active)
h.db.Model(&models.Task{}).Scopes(scopes.ScopeArchived).Count(&stats.Tasks.Archived)
h.db.Model(&models.Task{}).Scopes(scopes.ScopeCancelled).Count(&stats.Tasks.Cancelled)
h.db.Model(&models.Task{}).Where("created_at >= ?", thirtyDaysAgo).Count(&stats.Tasks.New30d)
// Task counts by status (using LEFT JOIN to handle tasks with no status)
// Note: These status counts use DB status names, not kanban categorization
h.db.Model(&models.Task{}).
Where("is_cancelled = ? AND is_archived = ?", false, false).
Scopes(scopes.ScopeActive).
Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
Where("LOWER(task_taskstatus.name) = ? OR task_taskstatus.id IS NULL", "pending").
Count(&stats.Tasks.Pending)
h.db.Model(&models.Task{}).
Where("is_cancelled = ? AND is_archived = ?", false, false).
Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
Where("LOWER(task_taskstatus.name) = ?", "in progress").
Scopes(scopes.ScopeActive, scopes.ScopeInProgress).
Count(&stats.Tasks.InProgress)
// Completed count: uses kanban completion logic (NextDueDate == nil AND has completions)
// See internal/task/predicates.IsCompleted for the definition
h.db.Model(&models.Task{}).
Where("is_cancelled = ? AND is_archived = ?", false, false).
Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
Where("LOWER(task_taskstatus.name) = ?", "completed").
Scopes(scopes.ScopeActive, scopes.ScopeCompleted).
Count(&stats.Tasks.Completed)
h.db.Model(&models.Task{}).
Where("is_cancelled = ? AND is_archived = ?", false, false).
Scopes(scopes.ScopeActive).
Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
Where("LOWER(task_taskstatus.name) = ?", "on hold").
Count(&stats.Tasks.OnHold)
// Overdue: past due date, not completed, not cancelled, not archived
// Overdue: uses consistent logic from internal/task/scopes.ScopeOverdue
// Effective date (COALESCE(next_due_date, due_date)) < now, active, not completed
h.db.Model(&models.Task{}).
Where("next_due_date < ? AND is_cancelled = ? AND is_archived = ?", now, false, false).
Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
Where("LOWER(task_taskstatus.name) NOT IN ? OR task_taskstatus.id IS NULL", []string{"completed", "cancelled"}).
Scopes(scopes.ScopeOverdue(now)).
Count(&stats.Tasks.Overdue)
// Contractor stats

View File

@@ -7,6 +7,7 @@ import (
"github.com/shopspring/decimal"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/task/categorization"
)
// TaskCategoryResponse represents a task category
@@ -365,53 +366,8 @@ func NewTaskCompletionWithTaskResponse(c *models.TaskCompletion, task *models.Ta
}
// DetermineKanbanColumn determines which kanban column a task belongs to.
// This is a wrapper around the Chain of Responsibility implementation in
// internal/task/categorization package. See that package for detailed
// documentation on the categorization logic.
//
// Deprecated: Use categorization.DetermineKanbanColumn directly for new code.
// Delegates to internal/task/categorization package which is the single source
// of truth for task categorization logic.
func DetermineKanbanColumn(task *models.Task, daysThreshold int) string {
// Import would cause circular dependency, so we replicate the logic here
// for backwards compatibility. The authoritative implementation is in
// internal/task/categorization/chain.go
if daysThreshold <= 0 {
daysThreshold = 30 // Default
}
now := time.Now().UTC()
threshold := now.AddDate(0, 0, daysThreshold)
// Priority order (Chain of Responsibility):
// 1. Cancelled (highest priority)
if task.IsCancelled {
return "cancelled_tasks"
}
// 2. Completed (one-time task with nil next_due_date and has completions)
if task.NextDueDate == nil && len(task.Completions) > 0 {
return "completed_tasks"
}
// 3. In Progress (status check)
if task.Status != nil && task.Status.Name == "In Progress" {
return "in_progress_tasks"
}
// 4. Overdue (next_due_date or due_date is in the past)
effectiveDate := task.NextDueDate
if effectiveDate == nil {
effectiveDate = task.DueDate
}
if effectiveDate != nil {
if effectiveDate.Before(now) {
return "overdue_tasks"
}
// 5. Due Soon (within threshold)
if effectiveDate.Before(threshold) {
return "due_soon_tasks"
}
}
// 6. Upcoming (default/fallback)
return "upcoming_tasks"
return categorization.DetermineKanbanColumn(task, daysThreshold)
}

View File

@@ -110,28 +110,66 @@ func (Task) TableName() string {
return "task_task"
}
// IsOverdue returns true if the task is past its due date and not completed
// IsOverdue returns true if the task is past its due date and not completed.
//
// IMPORTANT: This method delegates to the predicates package which is the
// single source of truth for task logic. It uses EffectiveDate (NextDueDate ?? DueDate)
// rather than just DueDate, ensuring consistency with kanban categorization.
//
// Deprecated: Prefer using task.IsOverdue(t, time.Now().UTC()) directly for explicit time control.
func (t *Task) IsOverdue() bool {
if t.DueDate == nil || t.IsCancelled || t.IsArchived {
// Delegate to predicates package - single source of truth
// Import is avoided here to prevent circular dependency.
// Logic must match predicates.IsOverdue exactly:
// - Check active (not cancelled, not archived)
// - Check not completed (NextDueDate != nil || no completions)
// - Check effective date < now
if t.IsCancelled || t.IsArchived {
return false
}
// Check if there's a completion
if len(t.Completions) > 0 {
// Completed check: NextDueDate == nil AND has completions
if t.NextDueDate == nil && len(t.Completions) > 0 {
return false
}
return time.Now().UTC().After(*t.DueDate)
// Effective date: NextDueDate ?? DueDate
effectiveDate := t.NextDueDate
if effectiveDate == nil {
effectiveDate = t.DueDate
}
if effectiveDate == nil {
return false
}
return effectiveDate.Before(time.Now().UTC())
}
// IsDueSoon returns true if the task is due within the specified days
// IsDueSoon returns true if the task is due within the specified days.
//
// IMPORTANT: This method uses EffectiveDate (NextDueDate ?? DueDate)
// rather than just DueDate, ensuring consistency with kanban categorization.
//
// Deprecated: Prefer using task.IsDueSoon(t, time.Now().UTC(), days) directly for explicit time control.
func (t *Task) IsDueSoon(days int) bool {
if t.DueDate == nil || t.IsCancelled || t.IsArchived {
// Delegate to predicates package logic - single source of truth
// Logic must match predicates.IsDueSoon exactly
if t.IsCancelled || t.IsArchived {
return false
}
if len(t.Completions) > 0 {
// Completed check: NextDueDate == nil AND has completions
if t.NextDueDate == nil && len(t.Completions) > 0 {
return false
}
threshold := time.Now().UTC().AddDate(0, 0, days)
return t.DueDate.Before(threshold) && !t.IsOverdue()
// Effective date: NextDueDate ?? DueDate
effectiveDate := t.NextDueDate
if effectiveDate == nil {
effectiveDate = t.DueDate
}
if effectiveDate == nil {
return false
}
now := time.Now().UTC()
threshold := now.AddDate(0, 0, days)
// Due soon = not overdue AND before threshold
return !effectiveDate.Before(now) && effectiveDate.Before(threshold)
}
// TaskCompletion represents the task_taskcompletion table

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

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

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

View File

@@ -1,12 +1,11 @@
package categorization
package categorization_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/task/categorization"
)
// Helper to create a time pointer
@@ -14,362 +13,224 @@ func timePtr(t time.Time) *time.Time {
return &t
}
// Helper to create a uint pointer
func uintPtr(v uint) *uint {
return &v
}
func TestCategorizeTask_PriorityOrder(t *testing.T) {
now := time.Now().UTC()
yesterday := now.AddDate(0, 0, -1)
in5Days := now.AddDate(0, 0, 5)
in60Days := now.AddDate(0, 0, 60)
inProgressStatus := &models.TaskStatus{Name: "In Progress"}
daysThreshold := 30
// Helper to create a completion with an ID
func makeCompletion(id uint) models.TaskCompletion {
c := models.TaskCompletion{CompletedAt: time.Now()}
c.ID = id
return c
}
tests := []struct {
name string
task *models.Task
expected categorization.KanbanColumn
}{
// Priority 1: Cancelled
{
name: "cancelled takes priority over everything",
task: &models.Task{
IsCancelled: true,
NextDueDate: timePtr(yesterday), // Would be overdue
Status: inProgressStatus, // Would be in progress
Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, // Would be completed if NextDueDate was nil
},
expected: categorization.ColumnCancelled,
},
// Helper to create a task with an ID
func makeTask(id uint) models.Task {
t := models.Task{}
t.ID = id
return t
}
// Priority 2: Completed
{
name: "completed: NextDueDate nil with completions",
task: &models.Task{
IsCancelled: false,
IsArchived: false,
NextDueDate: nil,
DueDate: timePtr(yesterday), // Would be overdue if not completed
Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}},
},
expected: categorization.ColumnCompleted,
},
{
name: "not completed when NextDueDate set (recurring task with completions)",
task: &models.Task{
IsCancelled: false,
IsArchived: false,
NextDueDate: timePtr(in5Days),
Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}},
},
expected: categorization.ColumnDueSoon, // Falls through to due soon
},
func TestCancelledHandler(t *testing.T) {
chain := NewChain()
// Priority 3: In Progress
{
name: "in progress takes priority over overdue",
task: &models.Task{
IsCancelled: false,
IsArchived: false,
NextDueDate: timePtr(yesterday), // Would be overdue
Status: inProgressStatus,
Completions: []models.TaskCompletion{},
},
expected: categorization.ColumnInProgress,
},
t.Run("cancelled task goes to cancelled column", func(t *testing.T) {
task := &models.Task{
IsCancelled: true,
}
result := chain.Categorize(task, 30)
assert.Equal(t, ColumnCancelled, result)
})
// Priority 4: Overdue
{
name: "overdue: effective date in past",
task: &models.Task{
IsCancelled: false,
IsArchived: false,
NextDueDate: timePtr(yesterday),
Completions: []models.TaskCompletion{},
},
expected: categorization.ColumnOverdue,
},
{
name: "overdue: uses DueDate when NextDueDate nil (no completions)",
task: &models.Task{
IsCancelled: false,
IsArchived: false,
NextDueDate: nil,
DueDate: timePtr(yesterday),
Completions: []models.TaskCompletion{},
},
expected: categorization.ColumnOverdue,
},
t.Run("cancelled task with due date still goes to cancelled", func(t *testing.T) {
dueDate := time.Now().AddDate(0, 0, -10) // 10 days ago (overdue)
task := &models.Task{
IsCancelled: true,
DueDate: &dueDate,
}
result := chain.Categorize(task, 30)
assert.Equal(t, ColumnCancelled, result)
})
}
// Priority 5: Due Soon
{
name: "due soon: within threshold",
task: &models.Task{
IsCancelled: false,
IsArchived: false,
NextDueDate: timePtr(in5Days),
Completions: []models.TaskCompletion{},
},
expected: categorization.ColumnDueSoon,
},
func TestCompletedHandler(t *testing.T) {
chain := NewChain()
// Priority 6: Upcoming (default)
{
name: "upcoming: beyond threshold",
task: &models.Task{
IsCancelled: false,
IsArchived: false,
NextDueDate: timePtr(in60Days),
Completions: []models.TaskCompletion{},
},
expected: categorization.ColumnUpcoming,
},
{
name: "upcoming: no due date",
task: &models.Task{
IsCancelled: false,
IsArchived: false,
NextDueDate: nil,
DueDate: nil,
Completions: []models.TaskCompletion{},
},
expected: categorization.ColumnUpcoming,
},
}
t.Run("one-time task with completion and no next_due_date goes to completed", func(t *testing.T) {
task := &models.Task{
NextDueDate: nil,
Completions: []models.TaskCompletion{makeCompletion(1)},
}
result := chain.Categorize(task, 30)
assert.Equal(t, ColumnCompleted, result)
})
t.Run("recurring task with completion but has next_due_date does NOT go to completed", func(t *testing.T) {
nextDue := time.Now().AddDate(0, 0, 30)
task := &models.Task{
NextDueDate: &nextDue,
Completions: []models.TaskCompletion{makeCompletion(1)},
}
result := chain.Categorize(task, 30)
// Should go to due_soon or upcoming, not completed
assert.NotEqual(t, ColumnCompleted, result)
})
t.Run("task with no completions does not go to completed", func(t *testing.T) {
task := &models.Task{
NextDueDate: nil,
Completions: []models.TaskCompletion{},
}
result := chain.Categorize(task, 30)
assert.NotEqual(t, ColumnCompleted, result)
})
}
func TestInProgressHandler(t *testing.T) {
chain := NewChain()
t.Run("task with In Progress status goes to in_progress column", func(t *testing.T) {
task := &models.Task{
Status: &models.TaskStatus{Name: "In Progress"},
}
result := chain.Categorize(task, 30)
assert.Equal(t, ColumnInProgress, result)
})
t.Run("task with Pending status does not go to in_progress", func(t *testing.T) {
task := &models.Task{
Status: &models.TaskStatus{Name: "Pending"},
}
result := chain.Categorize(task, 30)
assert.NotEqual(t, ColumnInProgress, result)
})
t.Run("task with nil status does not go to in_progress", func(t *testing.T) {
task := &models.Task{
Status: nil,
}
result := chain.Categorize(task, 30)
assert.NotEqual(t, ColumnInProgress, result)
})
}
func TestOverdueHandler(t *testing.T) {
chain := NewChain()
t.Run("task with past next_due_date goes to overdue", func(t *testing.T) {
pastDate := time.Now().AddDate(0, 0, -5) // 5 days ago
task := &models.Task{
NextDueDate: &pastDate,
Status: &models.TaskStatus{Name: "Pending"},
}
result := chain.Categorize(task, 30)
assert.Equal(t, ColumnOverdue, result)
})
t.Run("task with past due_date (no next_due_date) goes to overdue", func(t *testing.T) {
pastDate := time.Now().AddDate(0, 0, -5) // 5 days ago
task := &models.Task{
DueDate: &pastDate,
NextDueDate: nil,
Status: &models.TaskStatus{Name: "Pending"},
}
result := chain.Categorize(task, 30)
assert.Equal(t, ColumnOverdue, result)
})
t.Run("next_due_date takes precedence over due_date", func(t *testing.T) {
pastDueDate := time.Now().AddDate(0, 0, -10) // 10 days ago
futureNextDue := time.Now().AddDate(0, 0, 60) // 60 days from now
task := &models.Task{
DueDate: &pastDueDate,
NextDueDate: &futureNextDue,
Status: &models.TaskStatus{Name: "Pending"},
}
result := chain.Categorize(task, 30)
// Should be upcoming (60 days > 30 day threshold), not overdue
assert.Equal(t, ColumnUpcoming, result)
})
}
func TestDueSoonHandler(t *testing.T) {
chain := NewChain()
t.Run("task due within threshold goes to due_soon", func(t *testing.T) {
dueDate := time.Now().AddDate(0, 0, 15) // 15 days from now
task := &models.Task{
NextDueDate: &dueDate,
Status: &models.TaskStatus{Name: "Pending"},
}
result := chain.Categorize(task, 30) // 30 day threshold
assert.Equal(t, ColumnDueSoon, result)
})
t.Run("task due exactly at threshold goes to due_soon", func(t *testing.T) {
dueDate := time.Now().AddDate(0, 0, 29) // Just under 30 days
task := &models.Task{
NextDueDate: &dueDate,
Status: &models.TaskStatus{Name: "Pending"},
}
result := chain.Categorize(task, 30)
assert.Equal(t, ColumnDueSoon, result)
})
t.Run("custom threshold is respected", func(t *testing.T) {
dueDate := time.Now().AddDate(0, 0, 10) // 10 days from now
task := &models.Task{
NextDueDate: &dueDate,
Status: &models.TaskStatus{Name: "Pending"},
}
// With 7 day threshold, 10 days out should be upcoming, not due_soon
result := chain.Categorize(task, 7)
assert.Equal(t, ColumnUpcoming, result)
})
}
func TestUpcomingHandler(t *testing.T) {
chain := NewChain()
t.Run("task with future next_due_date beyond threshold goes to upcoming", func(t *testing.T) {
futureDate := time.Now().AddDate(0, 0, 60) // 60 days from now
task := &models.Task{
NextDueDate: &futureDate,
Status: &models.TaskStatus{Name: "Pending"},
}
result := chain.Categorize(task, 30)
assert.Equal(t, ColumnUpcoming, result)
})
t.Run("task with no due date goes to upcoming (default)", func(t *testing.T) {
task := &models.Task{
DueDate: nil,
NextDueDate: nil,
Status: &models.TaskStatus{Name: "Pending"},
}
result := chain.Categorize(task, 30)
assert.Equal(t, ColumnUpcoming, result)
})
}
func TestChainPriorityOrder(t *testing.T) {
chain := NewChain()
t.Run("cancelled takes priority over everything", func(t *testing.T) {
pastDate := time.Now().AddDate(0, 0, -10)
task := &models.Task{
IsCancelled: true,
DueDate: &pastDate,
NextDueDate: nil,
Completions: []models.TaskCompletion{makeCompletion(1)},
Status: &models.TaskStatus{Name: "In Progress"},
}
result := chain.Categorize(task, 30)
assert.Equal(t, ColumnCancelled, result)
})
t.Run("completed takes priority over in_progress", func(t *testing.T) {
task := &models.Task{
IsCancelled: false,
NextDueDate: nil,
Completions: []models.TaskCompletion{makeCompletion(1)},
Status: &models.TaskStatus{Name: "In Progress"},
}
result := chain.Categorize(task, 30)
assert.Equal(t, ColumnCompleted, result)
})
t.Run("in_progress takes priority over overdue", func(t *testing.T) {
pastDate := time.Now().AddDate(0, 0, -10)
task := &models.Task{
IsCancelled: false,
NextDueDate: &pastDate,
Status: &models.TaskStatus{Name: "In Progress"},
}
result := chain.Categorize(task, 30)
assert.Equal(t, ColumnInProgress, result)
})
t.Run("overdue takes priority over due_soon", func(t *testing.T) {
pastDate := time.Now().AddDate(0, 0, -1)
task := &models.Task{
IsCancelled: false,
NextDueDate: &pastDate,
Status: &models.TaskStatus{Name: "Pending"},
}
result := chain.Categorize(task, 30)
assert.Equal(t, ColumnOverdue, result)
})
}
func TestRecurringTaskScenarios(t *testing.T) {
chain := NewChain()
t.Run("annual task just completed should go to upcoming (next_due_date is 1 year out)", func(t *testing.T) {
nextYear := time.Now().AddDate(1, 0, 0)
task := &models.Task{
NextDueDate: &nextYear,
Completions: []models.TaskCompletion{makeCompletion(1)},
Status: &models.TaskStatus{Name: "Pending"}, // Reset after completion
Frequency: &models.TaskFrequency{Name: "Annually", Days: intPtr(365)},
}
result := chain.Categorize(task, 30)
assert.Equal(t, ColumnUpcoming, result)
})
t.Run("monthly task due in 2 weeks should go to due_soon", func(t *testing.T) {
twoWeeks := time.Now().AddDate(0, 0, 14)
task := &models.Task{
NextDueDate: &twoWeeks,
Completions: []models.TaskCompletion{makeCompletion(1)},
Status: &models.TaskStatus{Name: "Pending"},
Frequency: &models.TaskFrequency{Name: "Monthly", Days: intPtr(30)},
}
result := chain.Categorize(task, 30)
assert.Equal(t, ColumnDueSoon, result)
})
t.Run("weekly task that is overdue should go to overdue", func(t *testing.T) {
yesterday := time.Now().AddDate(0, 0, -1)
task := &models.Task{
NextDueDate: &yesterday,
Completions: []models.TaskCompletion{makeCompletion(1)},
Status: &models.TaskStatus{Name: "Pending"},
Frequency: &models.TaskFrequency{Name: "Weekly", Days: intPtr(7)},
}
result := chain.Categorize(task, 30)
assert.Equal(t, ColumnOverdue, result)
})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := categorization.CategorizeTask(tt.task, daysThreshold)
if result != tt.expected {
t.Errorf("CategorizeTask() = %v, expected %v", result, tt.expected)
}
})
}
}
func TestCategorizeTasksIntoColumns(t *testing.T) {
now := time.Now()
pastDate := now.AddDate(0, 0, -5)
soonDate := now.AddDate(0, 0, 15)
futureDate := now.AddDate(0, 0, 60)
now := time.Now().UTC()
yesterday := now.AddDate(0, 0, -1)
in5Days := now.AddDate(0, 0, 5)
in60Days := now.AddDate(0, 0, 60)
daysThreshold := 30
// Create tasks with proper IDs
task1 := makeTask(1)
task1.IsCancelled = true
tasks := []models.Task{
{BaseModel: models.BaseModel{ID: 1}, IsCancelled: true}, // Cancelled
{BaseModel: models.BaseModel{ID: 2}, NextDueDate: nil, Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}}, // Completed
{BaseModel: models.BaseModel{ID: 3}, Status: &models.TaskStatus{Name: "In Progress"}}, // In Progress
{BaseModel: models.BaseModel{ID: 4}, NextDueDate: timePtr(yesterday)}, // Overdue
{BaseModel: models.BaseModel{ID: 5}, NextDueDate: timePtr(in5Days)}, // Due Soon
{BaseModel: models.BaseModel{ID: 6}, NextDueDate: timePtr(in60Days)}, // Upcoming
{BaseModel: models.BaseModel{ID: 7}}, // Upcoming (no due date)
}
task2 := makeTask(2)
task2.NextDueDate = nil
task2.Completions = []models.TaskCompletion{makeCompletion(1)}
result := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold)
task3 := makeTask(3)
task3.Status = &models.TaskStatus{Name: "In Progress"}
task4 := makeTask(4)
task4.NextDueDate = &pastDate
task4.Status = &models.TaskStatus{Name: "Pending"}
task5 := makeTask(5)
task5.NextDueDate = &soonDate
task5.Status = &models.TaskStatus{Name: "Pending"}
task6 := makeTask(6)
task6.NextDueDate = &futureDate
task6.Status = &models.TaskStatus{Name: "Pending"}
tasks := []models.Task{task1, task2, task3, task4, task5, task6}
result := CategorizeTasksIntoColumns(tasks, 30)
assert.Len(t, result[ColumnCancelled], 1)
assert.Equal(t, uint(1), result[ColumnCancelled][0].ID)
assert.Len(t, result[ColumnCompleted], 1)
assert.Equal(t, uint(2), result[ColumnCompleted][0].ID)
assert.Len(t, result[ColumnInProgress], 1)
assert.Equal(t, uint(3), result[ColumnInProgress][0].ID)
assert.Len(t, result[ColumnOverdue], 1)
assert.Equal(t, uint(4), result[ColumnOverdue][0].ID)
assert.Len(t, result[ColumnDueSoon], 1)
assert.Equal(t, uint(5), result[ColumnDueSoon][0].ID)
assert.Len(t, result[ColumnUpcoming], 1)
assert.Equal(t, uint(6), result[ColumnUpcoming][0].ID)
// Check each column has the expected tasks
if len(result[categorization.ColumnCancelled]) != 1 || result[categorization.ColumnCancelled][0].ID != 1 {
t.Errorf("Expected task 1 in Cancelled column")
}
if len(result[categorization.ColumnCompleted]) != 1 || result[categorization.ColumnCompleted][0].ID != 2 {
t.Errorf("Expected task 2 in Completed column")
}
if len(result[categorization.ColumnInProgress]) != 1 || result[categorization.ColumnInProgress][0].ID != 3 {
t.Errorf("Expected task 3 in InProgress column")
}
if len(result[categorization.ColumnOverdue]) != 1 || result[categorization.ColumnOverdue][0].ID != 4 {
t.Errorf("Expected task 4 in Overdue column")
}
if len(result[categorization.ColumnDueSoon]) != 1 || result[categorization.ColumnDueSoon][0].ID != 5 {
t.Errorf("Expected task 5 in DueSoon column")
}
if len(result[categorization.ColumnUpcoming]) != 2 {
t.Errorf("Expected 2 tasks in Upcoming column, got %d", len(result[categorization.ColumnUpcoming]))
}
}
func TestDefaultThreshold(t *testing.T) {
func TestDetermineKanbanColumn_ReturnsString(t *testing.T) {
task := &models.Task{IsCancelled: true}
result := categorization.DetermineKanbanColumn(task, 30)
if result != "cancelled_tasks" {
t.Errorf("DetermineKanbanColumn() = %v, expected %v", result, "cancelled_tasks")
}
}
func TestKanbanColumnConstants(t *testing.T) {
// Verify column string values match expected API values
tests := []struct {
column categorization.KanbanColumn
expected string
}{
{categorization.ColumnOverdue, "overdue_tasks"},
{categorization.ColumnDueSoon, "due_soon_tasks"},
{categorization.ColumnUpcoming, "upcoming_tasks"},
{categorization.ColumnInProgress, "in_progress_tasks"},
{categorization.ColumnCompleted, "completed_tasks"},
{categorization.ColumnCancelled, "cancelled_tasks"},
}
for _, tt := range tests {
if tt.column.String() != tt.expected {
t.Errorf("Column %v.String() = %v, expected %v", tt.column, tt.column.String(), tt.expected)
}
}
}
func TestNewContext_DefaultThreshold(t *testing.T) {
task := &models.Task{}
// Test that 0 or negative threshold defaults to 30
ctx1 := NewContext(task, 0)
assert.Equal(t, 30, ctx1.DaysThreshold)
// Zero threshold should default to 30
ctx := categorization.NewContext(task, 0)
if ctx.DaysThreshold != 30 {
t.Errorf("NewContext with 0 threshold should default to 30, got %d", ctx.DaysThreshold)
}
ctx2 := NewContext(task, -5)
assert.Equal(t, 30, ctx2.DaysThreshold)
// Negative threshold should default to 30
ctx = categorization.NewContext(task, -5)
if ctx.DaysThreshold != 30 {
t.Errorf("NewContext with negative threshold should default to 30, got %d", ctx.DaysThreshold)
}
ctx3 := NewContext(task, 14)
assert.Equal(t, 14, ctx3.DaysThreshold)
}
// Helper to create int pointer
func intPtr(v int) *int {
return &v
// Positive threshold should be used
ctx = categorization.NewContext(task, 45)
if ctx.DaysThreshold != 45 {
t.Errorf("NewContext with 45 threshold should be 45, got %d", ctx.DaysThreshold)
}
}

View File

@@ -0,0 +1,668 @@
// Package task provides consistency tests that verify all three layers
// (predicates, scopes, and categorization) return identical results.
//
// These tests are critical for ensuring the DRY architecture is maintained.
// If any of these tests fail, it means the three layers have diverged and
// will produce inconsistent results in different parts of the application.
package task_test
import (
"os"
"testing"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/task/categorization"
"github.com/treytartt/casera-api/internal/task/predicates"
"github.com/treytartt/casera-api/internal/task/scopes"
)
// testDB holds the database connection for integration tests
var testDB *gorm.DB
// testUserID is a user ID that exists in the database for foreign key constraints
var testUserID uint = 1
// TestMain sets up the database connection for all tests in this package
func TestMain(m *testing.M) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
dsn = "host=localhost user=postgres password=postgres dbname=mycrib_test port=5432 sslmode=disable"
}
var err error
testDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
println("Skipping consistency integration tests: database not available")
println("Set TEST_DATABASE_URL to run these tests")
os.Exit(0)
}
sqlDB, err := testDB.DB()
if err != nil || sqlDB.Ping() != nil {
println("Failed to connect to database")
os.Exit(0)
}
println("Database connected, running consistency tests...")
code := m.Run()
cleanupTestData()
os.Exit(code)
}
func cleanupTestData() {
if testDB == nil {
return
}
testDB.Exec("DELETE FROM task_taskcompletion WHERE task_id IN (SELECT id FROM task_task WHERE title LIKE 'consistency_test_%')")
testDB.Exec("DELETE FROM task_task WHERE title LIKE 'consistency_test_%'")
testDB.Exec("DELETE FROM residence_residence WHERE name LIKE 'consistency_test_%'")
}
func timePtr(t time.Time) *time.Time {
return &t
}
func createResidence(t *testing.T) uint {
residence := &models.Residence{
Name: "consistency_test_" + time.Now().Format("20060102150405.000"),
OwnerID: testUserID,
IsActive: true,
}
if err := testDB.Create(residence).Error; err != nil {
t.Fatalf("Failed to create residence: %v", err)
}
return residence.ID
}
func createTask(t *testing.T, residenceID uint, task *models.Task) *models.Task {
task.ResidenceID = residenceID
task.Title = "consistency_test_" + task.Title
task.CreatedByID = testUserID
if err := testDB.Create(task).Error; err != nil {
t.Fatalf("Failed to create task: %v", err)
}
return task
}
func createCompletion(t *testing.T, taskID uint) {
completion := &models.TaskCompletion{
TaskID: taskID,
CompletedByID: testUserID,
CompletedAt: time.Now().UTC(),
}
if err := testDB.Create(completion).Error; err != nil {
t.Fatalf("Failed to create completion: %v", err)
}
}
// getInProgressStatusID returns the ID of the "In Progress" status
func getInProgressStatusID(t *testing.T) *uint {
var status models.TaskStatus
if err := testDB.Where("name = ?", "In Progress").First(&status).Error; err != nil {
t.Logf("In Progress status not found, skipping in-progress tests")
return nil
}
return &status.ID
}
// TaskTestCase defines a test scenario with expected categorization
type TaskTestCase struct {
Name string
Task *models.Task
HasCompletion bool
ExpectedColumn categorization.KanbanColumn
// Expected predicate results
ExpectCompleted bool
ExpectActive bool
ExpectOverdue bool
ExpectDueSoon bool
ExpectUpcoming bool
ExpectInProgress bool
}
// TestAllThreeLayersMatch is the master consistency test.
// It creates tasks in the database, then verifies that:
// 1. Predicates return the expected boolean values
// 2. Categorization returns the expected kanban column
// 3. Scopes return the same tasks that predicates would filter
func TestAllThreeLayersMatch(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createResidence(t)
defer cleanupTestData()
now := time.Now().UTC()
yesterday := now.AddDate(0, 0, -1)
in5Days := now.AddDate(0, 0, 5)
in60Days := now.AddDate(0, 0, 60)
daysThreshold := 30
inProgressStatusID := getInProgressStatusID(t)
// Define all test cases with expected results for each layer
testCases := []TaskTestCase{
{
Name: "overdue_active",
Task: &models.Task{
Title: "overdue_active",
NextDueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
},
ExpectedColumn: categorization.ColumnOverdue,
ExpectCompleted: false,
ExpectActive: true,
ExpectOverdue: true,
ExpectDueSoon: false,
ExpectUpcoming: false,
},
{
Name: "due_soon_active",
Task: &models.Task{
Title: "due_soon_active",
NextDueDate: timePtr(in5Days),
IsCancelled: false,
IsArchived: false,
},
ExpectedColumn: categorization.ColumnDueSoon,
ExpectCompleted: false,
ExpectActive: true,
ExpectOverdue: false,
ExpectDueSoon: true,
ExpectUpcoming: false,
},
{
Name: "upcoming_far_future",
Task: &models.Task{
Title: "upcoming_far_future",
NextDueDate: timePtr(in60Days),
IsCancelled: false,
IsArchived: false,
},
ExpectedColumn: categorization.ColumnUpcoming,
ExpectCompleted: false,
ExpectActive: true,
ExpectOverdue: false,
ExpectDueSoon: false,
ExpectUpcoming: true,
},
{
Name: "upcoming_no_due_date",
Task: &models.Task{
Title: "upcoming_no_due_date",
NextDueDate: nil,
DueDate: nil,
IsCancelled: false,
IsArchived: false,
},
ExpectedColumn: categorization.ColumnUpcoming,
ExpectCompleted: false,
ExpectActive: true,
ExpectOverdue: false,
ExpectDueSoon: false,
ExpectUpcoming: true,
},
{
Name: "completed_one_time",
Task: &models.Task{
Title: "completed_one_time",
NextDueDate: nil, // No next due date
DueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
},
HasCompletion: true, // Will create a completion
ExpectedColumn: categorization.ColumnCompleted,
ExpectCompleted: true,
ExpectActive: true,
ExpectOverdue: false, // Completed tasks are not overdue
ExpectDueSoon: false,
ExpectUpcoming: false,
},
{
Name: "cancelled_task",
Task: &models.Task{
Title: "cancelled_task",
NextDueDate: timePtr(yesterday), // Would be overdue if not cancelled
IsCancelled: true,
IsArchived: false,
},
ExpectedColumn: categorization.ColumnCancelled,
ExpectCompleted: false,
ExpectActive: false, // Cancelled is not active
ExpectOverdue: false, // Cancelled tasks are not overdue
ExpectDueSoon: false,
ExpectUpcoming: false,
},
{
Name: "archived_task",
Task: &models.Task{
Title: "archived_task",
NextDueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: true,
},
// Archived tasks don't appear in kanban (filtered out before categorization)
// but we test predicates still work
ExpectedColumn: categorization.ColumnOverdue, // Chain doesn't check archived
ExpectCompleted: false,
ExpectActive: false, // Archived is not active
ExpectOverdue: false, // Archived tasks are not overdue (IsOverdue checks IsActive)
ExpectDueSoon: false,
ExpectUpcoming: false,
},
{
Name: "recurring_with_completion",
Task: &models.Task{
Title: "recurring_with_completion",
NextDueDate: timePtr(in5Days), // Has next due date (recurring)
IsCancelled: false,
IsArchived: false,
},
HasCompletion: true,
ExpectedColumn: categorization.ColumnDueSoon, // Not completed because NextDueDate is set
ExpectCompleted: false, // Has completion but NextDueDate is set
ExpectActive: true,
ExpectOverdue: false,
ExpectDueSoon: true,
ExpectUpcoming: false,
},
{
Name: "overdue_uses_duedate_fallback",
Task: &models.Task{
Title: "overdue_uses_duedate_fallback",
NextDueDate: nil,
DueDate: timePtr(yesterday), // Falls back to DueDate
IsCancelled: false,
IsArchived: false,
},
ExpectedColumn: categorization.ColumnOverdue,
ExpectCompleted: false,
ExpectActive: true,
ExpectOverdue: true,
ExpectDueSoon: false,
ExpectUpcoming: false,
},
}
// Add in-progress test case only if status exists
if inProgressStatusID != nil {
testCases = append(testCases, TaskTestCase{
Name: "in_progress_overdue",
Task: &models.Task{
Title: "in_progress_overdue",
NextDueDate: timePtr(yesterday), // Would be overdue
StatusID: inProgressStatusID,
IsCancelled: false,
IsArchived: false,
},
ExpectedColumn: categorization.ColumnInProgress, // In Progress takes priority
ExpectCompleted: false,
ExpectActive: true,
ExpectOverdue: true, // Predicate says overdue (doesn't check status)
ExpectDueSoon: false,
ExpectUpcoming: false,
ExpectInProgress: true,
})
}
// Create all tasks in database
createdTasks := make(map[string]*models.Task)
for _, tc := range testCases {
task := createTask(t, residenceID, tc.Task)
if tc.HasCompletion {
createCompletion(t, task.ID)
}
createdTasks[tc.Name] = task
}
// Reload all tasks with preloads for predicate testing
var allTasks []models.Task
err := testDB.
Preload("Completions").
Preload("Status").
Where("residence_id = ?", residenceID).
Find(&allTasks).Error
if err != nil {
t.Fatalf("Failed to load tasks: %v", err)
}
// Create a map for easy lookup
taskMap := make(map[string]*models.Task)
for i := range allTasks {
// Strip the prefix for lookup
name := allTasks[i].Title[len("consistency_test_"):]
taskMap[name] = &allTasks[i]
}
// Test each case
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
task := taskMap[tc.Name]
if task == nil {
t.Fatalf("Task %s not found", tc.Name)
}
// ========== TEST PREDICATES ==========
t.Run("predicates", func(t *testing.T) {
if got := predicates.IsCompleted(task); got != tc.ExpectCompleted {
t.Errorf("IsCompleted() = %v, want %v", got, tc.ExpectCompleted)
}
if got := predicates.IsActive(task); got != tc.ExpectActive {
t.Errorf("IsActive() = %v, want %v", got, tc.ExpectActive)
}
if got := predicates.IsOverdue(task, now); got != tc.ExpectOverdue {
t.Errorf("IsOverdue() = %v, want %v", got, tc.ExpectOverdue)
}
if got := predicates.IsDueSoon(task, now, daysThreshold); got != tc.ExpectDueSoon {
t.Errorf("IsDueSoon() = %v, want %v", got, tc.ExpectDueSoon)
}
if got := predicates.IsUpcoming(task, now, daysThreshold); got != tc.ExpectUpcoming {
t.Errorf("IsUpcoming() = %v, want %v", got, tc.ExpectUpcoming)
}
if tc.ExpectInProgress {
if got := predicates.IsInProgress(task); got != tc.ExpectInProgress {
t.Errorf("IsInProgress() = %v, want %v", got, tc.ExpectInProgress)
}
}
})
// ========== TEST CATEGORIZATION ==========
t.Run("categorization", func(t *testing.T) {
got := categorization.CategorizeTask(task, daysThreshold)
if got != tc.ExpectedColumn {
t.Errorf("CategorizeTask() = %v, want %v", got, tc.ExpectedColumn)
}
})
})
}
// ========== TEST SCOPES MATCH PREDICATES ==========
// This is the critical test: query with scopes and verify results match predicate filtering
t.Run("scopes_match_predicates", func(t *testing.T) {
// Test ScopeActive
t.Run("ScopeActive", func(t *testing.T) {
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeActive).
Find(&scopeResults)
predicateCount := 0
for _, task := range allTasks {
if predicates.IsActive(&task) {
predicateCount++
}
}
if len(scopeResults) != predicateCount {
t.Errorf("ScopeActive returned %d, predicates found %d", len(scopeResults), predicateCount)
}
})
// Test ScopeCompleted
t.Run("ScopeCompleted", func(t *testing.T) {
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeCompleted).
Find(&scopeResults)
predicateCount := 0
for _, task := range allTasks {
if predicates.IsCompleted(&task) {
predicateCount++
}
}
if len(scopeResults) != predicateCount {
t.Errorf("ScopeCompleted returned %d, predicates found %d", len(scopeResults), predicateCount)
}
})
// Test ScopeOverdue
t.Run("ScopeOverdue", func(t *testing.T) {
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)).
Find(&scopeResults)
predicateCount := 0
for _, task := range allTasks {
if predicates.IsOverdue(&task, now) {
predicateCount++
}
}
if len(scopeResults) != predicateCount {
t.Errorf("ScopeOverdue returned %d, predicates found %d", len(scopeResults), predicateCount)
t.Logf("Scope results: %v", getTaskNames(scopeResults))
t.Logf("Predicate matches: %v", getPredicateMatches(allTasks, func(task *models.Task) bool {
return predicates.IsOverdue(task, now)
}))
}
})
// Test ScopeDueSoon
t.Run("ScopeDueSoon", func(t *testing.T) {
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeDueSoon(now, daysThreshold)).
Find(&scopeResults)
predicateCount := 0
for _, task := range allTasks {
if predicates.IsDueSoon(&task, now, daysThreshold) {
predicateCount++
}
}
if len(scopeResults) != predicateCount {
t.Errorf("ScopeDueSoon returned %d, predicates found %d", len(scopeResults), predicateCount)
}
})
// Test ScopeUpcoming
t.Run("ScopeUpcoming", func(t *testing.T) {
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeUpcoming(now, daysThreshold)).
Find(&scopeResults)
predicateCount := 0
for _, task := range allTasks {
if predicates.IsUpcoming(&task, now, daysThreshold) {
predicateCount++
}
}
if len(scopeResults) != predicateCount {
t.Errorf("ScopeUpcoming returned %d, predicates found %d", len(scopeResults), predicateCount)
}
})
// Test ScopeInProgress (if status exists)
if inProgressStatusID != nil {
t.Run("ScopeInProgress", func(t *testing.T) {
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeInProgress).
Find(&scopeResults)
predicateCount := 0
for _, task := range allTasks {
if predicates.IsInProgress(&task) {
predicateCount++
}
}
if len(scopeResults) != predicateCount {
t.Errorf("ScopeInProgress returned %d, predicates found %d", len(scopeResults), predicateCount)
}
})
}
})
// ========== TEST CATEGORIZATION MATCHES SCOPES FOR KANBAN ==========
// Verify that tasks categorized into each column match what scopes would return
t.Run("categorization_matches_scopes", func(t *testing.T) {
// Get categorization results
categorized := categorization.CategorizeTasksIntoColumns(allTasks, daysThreshold)
// Compare overdue column with scope
// NOTE: Scopes return tasks based on date criteria only.
// Categorization uses priority order (In Progress > Overdue).
// So a task that is overdue by date but "In Progress" won't be in ColumnOverdue.
// We need to compare scope results MINUS those with higher-priority categorization.
t.Run("overdue_column", func(t *testing.T) {
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Preload("Status").
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)).
Find(&scopeResults)
// Filter scope results to exclude tasks that would be categorized differently
// (i.e., tasks that are In Progress - higher priority than Overdue)
scopeOverdueNotInProgress := 0
for _, task := range scopeResults {
if !predicates.IsInProgress(&task) {
scopeOverdueNotInProgress++
}
}
// Count active overdue tasks in categorization
activeOverdue := 0
for _, task := range categorized[categorization.ColumnOverdue] {
if predicates.IsActive(&task) {
activeOverdue++
}
}
if scopeOverdueNotInProgress != activeOverdue {
t.Errorf("Overdue: scope returned %d (excluding in-progress), categorization has %d active",
scopeOverdueNotInProgress, activeOverdue)
}
})
// Compare due soon column with scope
t.Run("due_soon_column", func(t *testing.T) {
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeDueSoon(now, daysThreshold)).
Find(&scopeResults)
activeDueSoon := 0
for _, task := range categorized[categorization.ColumnDueSoon] {
if predicates.IsActive(&task) {
activeDueSoon++
}
}
if len(scopeResults) != activeDueSoon {
t.Errorf("DueSoon: scope returned %d, categorization has %d active",
len(scopeResults), activeDueSoon)
}
})
// Compare completed column with scope
t.Run("completed_column", func(t *testing.T) {
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeCompleted).
Find(&scopeResults)
if len(scopeResults) != len(categorized[categorization.ColumnCompleted]) {
t.Errorf("Completed: scope returned %d, categorization has %d",
len(scopeResults), len(categorized[categorization.ColumnCompleted]))
}
})
})
}
// TestSameDayOverdueConsistency is a regression test for the DATE vs TIMESTAMP bug.
// It verifies all three layers handle same-day tasks consistently.
func TestSameDayOverdueConsistency(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createResidence(t)
defer cleanupTestData()
// Create a task due at midnight today
now := time.Now().UTC()
todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
task := createTask(t, residenceID, &models.Task{
Title: "same_day_midnight",
NextDueDate: timePtr(todayMidnight),
IsCancelled: false,
IsArchived: false,
})
// Reload with preloads
var loadedTask models.Task
testDB.Preload("Completions").Preload("Status").First(&loadedTask, task.ID)
// All three layers should agree
predicateResult := predicates.IsOverdue(&loadedTask, now)
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)).
Find(&scopeResults)
scopeResult := len(scopeResults) == 1
categorizationResult := categorization.CategorizeTask(&loadedTask, 30) == categorization.ColumnOverdue
// If current time is after midnight, all should say overdue
if now.After(todayMidnight) {
if !predicateResult {
t.Error("Predicate says NOT overdue, but time is after midnight")
}
if !scopeResult {
t.Error("Scope says NOT overdue, but time is after midnight")
}
if !categorizationResult {
t.Error("Categorization says NOT overdue, but time is after midnight")
}
}
// Most importantly: all three must agree
if predicateResult != scopeResult {
t.Errorf("INCONSISTENCY: predicate=%v, scope=%v", predicateResult, scopeResult)
}
if predicateResult != categorizationResult {
t.Errorf("INCONSISTENCY: predicate=%v, categorization=%v", predicateResult, categorizationResult)
}
}
// Helper functions
func getTaskNames(tasks []models.Task) []string {
names := make([]string, len(tasks))
for i, t := range tasks {
names[i] = t.Title
}
return names
}
func getPredicateMatches(tasks []models.Task, predicate func(*models.Task) bool) []string {
var names []string
for _, t := range tasks {
if predicate(&t) {
names = append(names, t.Title)
}
}
return names
}

View File

@@ -0,0 +1,189 @@
// Package predicates provides pure predicate functions for task logic.
// These functions are the SINGLE SOURCE OF TRUTH for all task-related business logic.
//
// IMPORTANT: The scopes in ../scopes/scopes.go must mirror these predicates exactly.
// Any change to predicate logic MUST be reflected in the corresponding scope.
// Tests verify consistency between predicates and scopes.
package predicates
import (
"time"
"github.com/treytartt/casera-api/internal/models"
)
// =============================================================================
// STATE PREDICATES
// =============================================================================
// IsCompleted returns true if a task is considered "completed" per kanban rules.
//
// A task is completed when:
// - NextDueDate is nil (no future occurrence scheduled)
// - AND it has at least one completion record
//
// This applies to one-time tasks. Recurring tasks always have a NextDueDate
// after completion, so they never enter the "completed" state permanently.
//
// SQL equivalent (in scopes.go ScopeCompleted):
//
// next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)
func IsCompleted(task *models.Task) bool {
return task.NextDueDate == nil && len(task.Completions) > 0
}
// IsActive returns true if the task is not cancelled and not archived.
// Active tasks are eligible for display in the kanban board.
//
// SQL equivalent (in scopes.go ScopeActive):
//
// is_cancelled = false AND is_archived = false
func IsActive(task *models.Task) bool {
return !task.IsCancelled && !task.IsArchived
}
// IsCancelled returns true if the task has been cancelled.
//
// SQL equivalent:
//
// is_cancelled = true
func IsCancelled(task *models.Task) bool {
return task.IsCancelled
}
// IsArchived returns true if the task has been archived.
//
// SQL equivalent:
//
// is_archived = true
func IsArchived(task *models.Task) bool {
return task.IsArchived
}
// IsInProgress returns true if the task has status "In Progress".
//
// SQL equivalent (in scopes.go ScopeInProgress):
//
// task_taskstatus.name = 'In Progress'
func IsInProgress(task *models.Task) bool {
return task.Status != nil && task.Status.Name == "In Progress"
}
// =============================================================================
// DATE PREDICATES
// =============================================================================
// EffectiveDate returns the date used for scheduling calculations.
//
// For recurring tasks that have been completed at least once, NextDueDate
// contains the next occurrence. For new tasks or one-time tasks, we fall
// back to DueDate.
//
// Returns nil if task has no due date set.
//
// SQL equivalent:
//
// COALESCE(next_due_date, due_date)
func EffectiveDate(task *models.Task) *time.Time {
if task.NextDueDate != nil {
return task.NextDueDate
}
return task.DueDate
}
// IsOverdue returns true if the task's effective date is in the past.
//
// A task is overdue when:
// - It has an effective date (NextDueDate or DueDate)
// - That date is before the given time
// - The task is not completed, cancelled, or archived
//
// SQL equivalent (in scopes.go ScopeOverdue):
//
// COALESCE(next_due_date, due_date) < ?
// AND NOT (next_due_date IS NULL AND EXISTS completion)
// AND is_cancelled = false AND is_archived = false
func IsOverdue(task *models.Task, now time.Time) bool {
if !IsActive(task) || IsCompleted(task) {
return false
}
effectiveDate := EffectiveDate(task)
if effectiveDate == nil {
return false
}
return effectiveDate.Before(now)
}
// IsDueSoon returns true if the task's effective date is within the threshold.
//
// A task is "due soon" when:
// - It has an effective date (NextDueDate or DueDate)
// - That date is >= now AND < (now + daysThreshold)
// - The task is not completed, cancelled, archived, or already overdue
//
// SQL equivalent (in scopes.go ScopeDueSoon):
//
// COALESCE(next_due_date, due_date) >= ?
// AND COALESCE(next_due_date, due_date) < ?
// AND NOT (next_due_date IS NULL AND EXISTS completion)
// AND is_cancelled = false AND is_archived = false
func IsDueSoon(task *models.Task, now time.Time, daysThreshold int) bool {
if !IsActive(task) || IsCompleted(task) {
return false
}
effectiveDate := EffectiveDate(task)
if effectiveDate == nil {
return false
}
threshold := now.AddDate(0, 0, daysThreshold)
// Due soon = not overdue AND before threshold
return !effectiveDate.Before(now) && effectiveDate.Before(threshold)
}
// IsUpcoming returns true if the task is due after the threshold or has no due date.
//
// A task is "upcoming" when:
// - It has no effective date, OR
// - Its effective date is >= (now + daysThreshold)
// - The task is not completed, cancelled, or archived
//
// This is the default category for tasks that don't match other criteria.
func IsUpcoming(task *models.Task, now time.Time, daysThreshold int) bool {
if !IsActive(task) || IsCompleted(task) {
return false
}
effectiveDate := EffectiveDate(task)
if effectiveDate == nil {
return true // No due date = upcoming
}
threshold := now.AddDate(0, 0, daysThreshold)
return !effectiveDate.Before(threshold)
}
// =============================================================================
// COMPLETION HELPERS
// =============================================================================
// HasCompletions returns true if the task has at least one completion record.
func HasCompletions(task *models.Task) bool {
return len(task.Completions) > 0
}
// CompletionCount returns the number of completions for a task.
func CompletionCount(task *models.Task) int {
return len(task.Completions)
}
// =============================================================================
// RECURRING TASK HELPERS
// =============================================================================
// IsRecurring returns true if the task has a recurring frequency.
func IsRecurring(task *models.Task) bool {
return task.Frequency != nil && task.Frequency.Days != nil && *task.Frequency.Days > 0
}
// IsOneTime returns true if the task is a one-time (non-recurring) task.
func IsOneTime(task *models.Task) bool {
return !IsRecurring(task)
}

View File

@@ -0,0 +1,522 @@
package predicates_test
import (
"testing"
"time"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/task/predicates"
)
// Helper to create a time pointer
func timePtr(t time.Time) *time.Time {
return &t
}
func TestIsCompleted(t *testing.T) {
tests := []struct {
name string
task *models.Task
expected bool
}{
{
name: "completed: NextDueDate nil with completions",
task: &models.Task{
NextDueDate: nil,
Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}},
},
expected: true,
},
{
name: "not completed: NextDueDate set with completions",
task: &models.Task{
NextDueDate: timePtr(time.Now().AddDate(0, 0, 7)),
Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}},
},
expected: false,
},
{
name: "not completed: NextDueDate nil without completions",
task: &models.Task{
NextDueDate: nil,
Completions: []models.TaskCompletion{},
},
expected: false,
},
{
name: "not completed: NextDueDate set without completions",
task: &models.Task{
NextDueDate: timePtr(time.Now().AddDate(0, 0, 7)),
Completions: []models.TaskCompletion{},
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := predicates.IsCompleted(tt.task)
if result != tt.expected {
t.Errorf("IsCompleted() = %v, expected %v", result, tt.expected)
}
})
}
}
func TestIsActive(t *testing.T) {
tests := []struct {
name string
task *models.Task
expected bool
}{
{
name: "active: not cancelled, not archived",
task: &models.Task{IsCancelled: false, IsArchived: false},
expected: true,
},
{
name: "not active: cancelled",
task: &models.Task{IsCancelled: true, IsArchived: false},
expected: false,
},
{
name: "not active: archived",
task: &models.Task{IsCancelled: false, IsArchived: true},
expected: false,
},
{
name: "not active: both cancelled and archived",
task: &models.Task{IsCancelled: true, IsArchived: true},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := predicates.IsActive(tt.task)
if result != tt.expected {
t.Errorf("IsActive() = %v, expected %v", result, tt.expected)
}
})
}
}
func TestIsInProgress(t *testing.T) {
inProgressStatus := &models.TaskStatus{Name: "In Progress"}
pendingStatus := &models.TaskStatus{Name: "Pending"}
tests := []struct {
name string
task *models.Task
expected bool
}{
{
name: "in progress: status is In Progress",
task: &models.Task{Status: inProgressStatus},
expected: true,
},
{
name: "not in progress: status is Pending",
task: &models.Task{Status: pendingStatus},
expected: false,
},
{
name: "not in progress: no status",
task: &models.Task{Status: nil},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := predicates.IsInProgress(tt.task)
if result != tt.expected {
t.Errorf("IsInProgress() = %v, expected %v", result, tt.expected)
}
})
}
}
func TestEffectiveDate(t *testing.T) {
now := time.Now()
nextWeek := now.AddDate(0, 0, 7)
nextMonth := now.AddDate(0, 1, 0)
tests := []struct {
name string
task *models.Task
expected *time.Time
}{
{
name: "prefers NextDueDate when both set",
task: &models.Task{
NextDueDate: timePtr(nextWeek),
DueDate: timePtr(nextMonth),
},
expected: timePtr(nextWeek),
},
{
name: "falls back to DueDate when NextDueDate nil",
task: &models.Task{
NextDueDate: nil,
DueDate: timePtr(nextMonth),
},
expected: timePtr(nextMonth),
},
{
name: "returns nil when both nil",
task: &models.Task{
NextDueDate: nil,
DueDate: nil,
},
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := predicates.EffectiveDate(tt.task)
if tt.expected == nil {
if result != nil {
t.Errorf("EffectiveDate() = %v, expected nil", result)
}
} else {
if result == nil {
t.Errorf("EffectiveDate() = nil, expected %v", tt.expected)
} else if !result.Equal(*tt.expected) {
t.Errorf("EffectiveDate() = %v, expected %v", result, tt.expected)
}
}
})
}
}
func TestIsOverdue(t *testing.T) {
now := time.Now().UTC()
yesterday := now.AddDate(0, 0, -1)
tomorrow := now.AddDate(0, 0, 1)
tests := []struct {
name string
task *models.Task
now time.Time
expected bool
}{
{
name: "overdue: effective date in past",
task: &models.Task{
NextDueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
},
now: now,
expected: true,
},
{
name: "not overdue: effective date in future",
task: &models.Task{
NextDueDate: timePtr(tomorrow),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
},
now: now,
expected: false,
},
{
name: "not overdue: cancelled task",
task: &models.Task{
NextDueDate: timePtr(yesterday),
IsCancelled: true,
IsArchived: false,
Completions: []models.TaskCompletion{},
},
now: now,
expected: false,
},
{
name: "not overdue: archived task",
task: &models.Task{
NextDueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: true,
Completions: []models.TaskCompletion{},
},
now: now,
expected: false,
},
{
name: "not overdue: completed task (NextDueDate nil with completions)",
task: &models.Task{
NextDueDate: nil,
DueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}},
},
now: now,
expected: false,
},
{
name: "not overdue: no due date",
task: &models.Task{
NextDueDate: nil,
DueDate: nil,
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
},
now: now,
expected: false,
},
{
name: "overdue: uses DueDate when NextDueDate nil (no completions)",
task: &models.Task{
NextDueDate: nil,
DueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
},
now: now,
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := predicates.IsOverdue(tt.task, tt.now)
if result != tt.expected {
t.Errorf("IsOverdue() = %v, expected %v", result, tt.expected)
}
})
}
}
func TestIsDueSoon(t *testing.T) {
now := time.Now().UTC()
yesterday := now.AddDate(0, 0, -1)
in5Days := now.AddDate(0, 0, 5)
in60Days := now.AddDate(0, 0, 60)
tests := []struct {
name string
task *models.Task
now time.Time
daysThreshold int
expected bool
}{
{
name: "due soon: within threshold",
task: &models.Task{
NextDueDate: timePtr(in5Days),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
},
now: now,
daysThreshold: 30,
expected: true,
},
{
name: "not due soon: beyond threshold",
task: &models.Task{
NextDueDate: timePtr(in60Days),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
},
now: now,
daysThreshold: 30,
expected: false,
},
{
name: "not due soon: overdue (in past)",
task: &models.Task{
NextDueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
},
now: now,
daysThreshold: 30,
expected: false,
},
{
name: "not due soon: cancelled",
task: &models.Task{
NextDueDate: timePtr(in5Days),
IsCancelled: true,
IsArchived: false,
Completions: []models.TaskCompletion{},
},
now: now,
daysThreshold: 30,
expected: false,
},
{
name: "not due soon: completed",
task: &models.Task{
NextDueDate: nil,
DueDate: timePtr(in5Days),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}},
},
now: now,
daysThreshold: 30,
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := predicates.IsDueSoon(tt.task, tt.now, tt.daysThreshold)
if result != tt.expected {
t.Errorf("IsDueSoon() = %v, expected %v", result, tt.expected)
}
})
}
}
func TestIsUpcoming(t *testing.T) {
now := time.Now().UTC()
in5Days := now.AddDate(0, 0, 5)
in60Days := now.AddDate(0, 0, 60)
tests := []struct {
name string
task *models.Task
now time.Time
daysThreshold int
expected bool
}{
{
name: "upcoming: beyond threshold",
task: &models.Task{
NextDueDate: timePtr(in60Days),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
},
now: now,
daysThreshold: 30,
expected: true,
},
{
name: "upcoming: no due date",
task: &models.Task{
NextDueDate: nil,
DueDate: nil,
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
},
now: now,
daysThreshold: 30,
expected: true,
},
{
name: "not upcoming: within due soon threshold",
task: &models.Task{
NextDueDate: timePtr(in5Days),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
},
now: now,
daysThreshold: 30,
expected: false,
},
{
name: "not upcoming: cancelled",
task: &models.Task{
NextDueDate: timePtr(in60Days),
IsCancelled: true,
IsArchived: false,
Completions: []models.TaskCompletion{},
},
now: now,
daysThreshold: 30,
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := predicates.IsUpcoming(tt.task, tt.now, tt.daysThreshold)
if result != tt.expected {
t.Errorf("IsUpcoming() = %v, expected %v", result, tt.expected)
}
})
}
}
func TestHasCompletions(t *testing.T) {
tests := []struct {
name string
task *models.Task
expected bool
}{
{
name: "has completions",
task: &models.Task{Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}},
expected: true,
},
{
name: "no completions",
task: &models.Task{Completions: []models.TaskCompletion{}},
expected: false,
},
{
name: "nil completions",
task: &models.Task{Completions: nil},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := predicates.HasCompletions(tt.task)
if result != tt.expected {
t.Errorf("HasCompletions() = %v, expected %v", result, tt.expected)
}
})
}
}
func TestIsRecurring(t *testing.T) {
days := 7
tests := []struct {
name string
task *models.Task
expected bool
}{
{
name: "recurring: frequency with days",
task: &models.Task{Frequency: &models.TaskFrequency{Days: &days}},
expected: true,
},
{
name: "not recurring: frequency without days (one-time)",
task: &models.Task{Frequency: &models.TaskFrequency{Days: nil}},
expected: false,
},
{
name: "not recurring: no frequency",
task: &models.Task{Frequency: nil},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := predicates.IsRecurring(tt.task)
if result != tt.expected {
t.Errorf("IsRecurring() = %v, expected %v", result, tt.expected)
}
})
}
}

View File

@@ -0,0 +1,274 @@
// Package scopes provides GORM scope functions that mirror the predicates.
// These scopes allow efficient database-level filtering using the same logic
// as the predicates in ../predicates/predicates.go.
//
// IMPORTANT: These scopes must produce the same results as their predicate counterparts.
// Any change to a predicate MUST be reflected in the corresponding scope.
// Tests verify consistency between predicates and scopes.
//
// Each scope includes a comment referencing its predicate counterpart for easy cross-reference.
package scopes
import (
"time"
"gorm.io/gorm"
)
// =============================================================================
// STATE SCOPES
// =============================================================================
// ScopeActive filters to tasks that are not cancelled and not archived.
// Active tasks are eligible for display in the kanban board.
//
// Predicate equivalent: IsActive(task)
//
// SQL: is_cancelled = false AND is_archived = false
func ScopeActive(db *gorm.DB) *gorm.DB {
return db.Where("is_cancelled = ? AND is_archived = ?", false, false)
}
// ScopeCancelled filters to cancelled tasks only.
//
// Predicate equivalent: IsCancelled(task)
//
// SQL: is_cancelled = true
func ScopeCancelled(db *gorm.DB) *gorm.DB {
return db.Where("is_cancelled = ?", true)
}
// ScopeArchived filters to archived tasks only.
//
// Predicate equivalent: IsArchived(task)
//
// SQL: is_archived = true
func ScopeArchived(db *gorm.DB) *gorm.DB {
return db.Where("is_archived = ?", true)
}
// ScopeCompleted filters to completed tasks.
//
// A task is completed when NextDueDate is nil AND it has at least one completion.
//
// Predicate equivalent: IsCompleted(task)
//
// SQL: next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)
func ScopeCompleted(db *gorm.DB) *gorm.DB {
return db.Where(
"next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)",
)
}
// ScopeNotCompleted excludes completed tasks.
//
// A task is NOT completed when it either has a NextDueDate OR has no completions.
//
// Predicate equivalent: !IsCompleted(task)
//
// SQL: NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))
func ScopeNotCompleted(db *gorm.DB) *gorm.DB {
return db.Where(
"NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))",
)
}
// ScopeInProgress filters to tasks with status "In Progress".
//
// Predicate equivalent: IsInProgress(task)
//
// SQL: Joins task_taskstatus and filters by name = 'In Progress'
func ScopeInProgress(db *gorm.DB) *gorm.DB {
return db.Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
Where("task_taskstatus.name = ?", "In Progress")
}
// ScopeNotInProgress excludes tasks with status "In Progress".
//
// Predicate equivalent: !IsInProgress(task)
func ScopeNotInProgress(db *gorm.DB) *gorm.DB {
return db.Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
Where("task_taskstatus.name != ? OR task_taskstatus.name IS NULL", "In Progress")
}
// =============================================================================
// DATE SCOPES
// =============================================================================
// ScopeOverdue returns a scope for overdue tasks.
//
// A task is overdue when its effective date (COALESCE(next_due_date, due_date))
// is before the given time, and it's active and not completed.
//
// Predicate equivalent: IsOverdue(task, now)
//
// SQL: COALESCE(next_due_date, due_date) < ?::timestamp AND active AND not_completed
//
// NOTE: We explicitly cast to timestamp because PostgreSQL DATE columns compared
// against string literals (which is how GORM passes time.Time) use date comparison,
// not timestamp comparison. For example:
// - '2025-12-07'::date < '2025-12-07 17:00:00' = false (compares dates only)
// - '2025-12-07'::date < '2025-12-07 17:00:00'::timestamp = true (compares timestamp)
func ScopeOverdue(now time.Time) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Scopes(ScopeActive, ScopeNotCompleted).
Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", now)
}
}
// ScopeDueSoon returns a scope for tasks due within the threshold.
//
// A task is "due soon" when its effective date is >= now AND < (now + threshold),
// and it's active and not completed.
//
// Predicate equivalent: IsDueSoon(task, now, daysThreshold)
//
// SQL: COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp
//
// AND COALESCE(next_due_date, due_date)::timestamp < ?::timestamp
// AND active AND not_completed
//
// NOTE: We explicitly cast to timestamp for consistent comparison with DATE columns.
// See ScopeOverdue for detailed explanation.
func ScopeDueSoon(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
threshold := now.AddDate(0, 0, daysThreshold)
return db.Scopes(ScopeActive, ScopeNotCompleted).
Where("COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp", now).
Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", threshold)
}
}
// ScopeUpcoming returns a scope for tasks due after the threshold or with no due date.
//
// A task is "upcoming" when its effective date is >= (now + threshold) OR is null,
// and it's active and not completed.
//
// Predicate equivalent: IsUpcoming(task, now, daysThreshold)
//
// SQL: (COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp OR (next_due_date IS NULL AND due_date IS NULL))
//
// AND active AND not_completed
//
// NOTE: We explicitly cast to timestamp for consistent comparison with DATE columns.
// See ScopeOverdue for detailed explanation.
func ScopeUpcoming(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
threshold := now.AddDate(0, 0, daysThreshold)
return db.Scopes(ScopeActive, ScopeNotCompleted).
Where(
"COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp OR (next_due_date IS NULL AND due_date IS NULL)",
threshold,
)
}
}
// ScopeDueInRange returns a scope for tasks with effective date in a range.
//
// SQL: COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp
//
// AND COALESCE(next_due_date, due_date)::timestamp < ?::timestamp
//
// NOTE: We explicitly cast to timestamp for consistent comparison with DATE columns.
// See ScopeOverdue for detailed explanation.
func ScopeDueInRange(start, end time.Time) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.
Where("COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp", start).
Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", end)
}
}
// ScopeHasDueDate filters to tasks that have an effective due date.
//
// SQL: (next_due_date IS NOT NULL OR due_date IS NOT NULL)
func ScopeHasDueDate(db *gorm.DB) *gorm.DB {
return db.Where("next_due_date IS NOT NULL OR due_date IS NOT NULL")
}
// ScopeNoDueDate filters to tasks that have no effective due date.
//
// SQL: next_due_date IS NULL AND due_date IS NULL
func ScopeNoDueDate(db *gorm.DB) *gorm.DB {
return db.Where("next_due_date IS NULL AND due_date IS NULL")
}
// =============================================================================
// FILTER SCOPES
// =============================================================================
// ScopeForResidence filters tasks by a single residence ID.
//
// SQL: residence_id = ?
func ScopeForResidence(residenceID uint) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("residence_id = ?", residenceID)
}
}
// ScopeForResidences filters tasks by multiple residence IDs.
//
// SQL: residence_id IN (?)
func ScopeForResidences(residenceIDs []uint) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if len(residenceIDs) == 0 {
// Return empty result if no residence IDs provided
return db.Where("1 = 0")
}
return db.Where("residence_id IN ?", residenceIDs)
}
}
// ScopeHasCompletions filters to tasks that have at least one completion.
//
// Predicate equivalent: HasCompletions(task)
//
// SQL: EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)
func ScopeHasCompletions(db *gorm.DB) *gorm.DB {
return db.Where(
"EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)",
)
}
// ScopeNoCompletions filters to tasks that have no completions.
//
// Predicate equivalent: !HasCompletions(task)
//
// SQL: NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)
func ScopeNoCompletions(db *gorm.DB) *gorm.DB {
return db.Where(
"NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)",
)
}
// =============================================================================
// ORDERING
// =============================================================================
// ScopeOrderByDueDate orders tasks by effective due date ascending, nulls last.
//
// SQL: ORDER BY COALESCE(next_due_date, due_date) ASC NULLS LAST
func ScopeOrderByDueDate(db *gorm.DB) *gorm.DB {
return db.Order("COALESCE(next_due_date, due_date) ASC NULLS LAST")
}
// ScopeOrderByPriority orders tasks by priority level descending (urgent first).
//
// SQL: ORDER BY priority_id DESC
func ScopeOrderByPriority(db *gorm.DB) *gorm.DB {
return db.Order("priority_id DESC")
}
// ScopeOrderByCreatedAt orders tasks by creation date descending (newest first).
//
// SQL: ORDER BY created_at DESC
func ScopeOrderByCreatedAt(db *gorm.DB) *gorm.DB {
return db.Order("created_at DESC")
}
// ScopeKanbanOrder applies the standard kanban ordering.
//
// SQL: ORDER BY COALESCE(next_due_date, due_date) ASC NULLS LAST, priority_id DESC, created_at DESC
func ScopeKanbanOrder(db *gorm.DB) *gorm.DB {
return db.Order("COALESCE(next_due_date, due_date) ASC NULLS LAST, priority_id DESC, created_at DESC")
}

View File

@@ -0,0 +1,706 @@
package scopes_test
import (
"os"
"testing"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/task/predicates"
"github.com/treytartt/casera-api/internal/task/scopes"
)
// testDB holds the database connection for integration tests
var testDB *gorm.DB
// TestMain sets up the database connection for all tests in this package
func TestMain(m *testing.M) {
// Get database URL from environment or use default
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
dsn = "host=localhost user=postgres password=postgres dbname=mycrib_test port=5432 sslmode=disable"
}
var err error
testDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
// Print message and skip tests if database is not available
println("Skipping scope integration tests: database not available")
println("Set TEST_DATABASE_URL to run these tests")
println("Error:", err.Error())
os.Exit(0)
}
// Verify connection works
sqlDB, err := testDB.DB()
if err != nil {
println("Failed to get underlying DB:", err.Error())
os.Exit(0)
}
if err := sqlDB.Ping(); err != nil {
println("Failed to ping database:", err.Error())
os.Exit(0)
}
println("Database connected successfully, running integration tests...")
// Run migrations for test tables
err = testDB.AutoMigrate(
&models.Task{},
&models.TaskCompletion{},
&models.TaskStatus{},
&models.Residence{},
)
if err != nil {
os.Exit(1)
}
// Run tests
code := m.Run()
// Cleanup
cleanupTestData()
os.Exit(code)
}
// cleanupTestData removes all test data
func cleanupTestData() {
if testDB == nil {
return
}
testDB.Exec("DELETE FROM task_taskcompletion WHERE task_id IN (SELECT id FROM task_task WHERE title LIKE 'test_%')")
testDB.Exec("DELETE FROM task_task WHERE title LIKE 'test_%'")
testDB.Exec("DELETE FROM task_taskstatus WHERE name LIKE 'test_%'")
testDB.Exec("DELETE FROM residence_residence WHERE name LIKE 'test_%'")
}
// Helper to create a time pointer
func timePtr(t time.Time) *time.Time {
return &t
}
// testUserID is a user ID that exists in the database for foreign key constraints
var testUserID uint = 1
// createTestResidence creates a test residence and returns its ID
func createTestResidence(t *testing.T) uint {
residence := &models.Residence{
Name: "test_residence_" + time.Now().Format("20060102150405"),
OwnerID: testUserID,
IsActive: true,
}
if err := testDB.Create(residence).Error; err != nil {
t.Fatalf("Failed to create test residence: %v", err)
}
return residence.ID
}
// createTestStatus creates a test status and returns it
func createTestStatus(t *testing.T, name string) *models.TaskStatus {
status := &models.TaskStatus{
Name: "test_" + name,
}
if err := testDB.Create(status).Error; err != nil {
t.Fatalf("Failed to create test status: %v", err)
}
return status
}
// createTestTask creates a task with the given properties
func createTestTask(t *testing.T, residenceID uint, task *models.Task) *models.Task {
task.ResidenceID = residenceID
task.Title = "test_" + task.Title
task.CreatedByID = testUserID // Required foreign key
if err := testDB.Create(task).Error; err != nil {
t.Fatalf("Failed to create test task: %v", err)
}
return task
}
// createTestCompletion creates a completion for a task
func createTestCompletion(t *testing.T, taskID uint) *models.TaskCompletion {
completion := &models.TaskCompletion{
TaskID: taskID,
CompletedByID: testUserID, // Required foreign key
CompletedAt: time.Now().UTC(),
}
if err := testDB.Create(completion).Error; err != nil {
t.Fatalf("Failed to create test completion: %v", err)
}
return completion
}
// TestScopeActiveMatchesPredicate verifies ScopeActive produces same results as IsActive
func TestScopeActiveMatchesPredicate(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
// Create tasks with different active states
tasks := []*models.Task{
{Title: "active_task", IsCancelled: false, IsArchived: false},
{Title: "cancelled_task", IsCancelled: true, IsArchived: false},
{Title: "archived_task", IsCancelled: false, IsArchived: true},
{Title: "both_task", IsCancelled: true, IsArchived: true},
}
for _, task := range tasks {
createTestTask(t, residenceID, task)
}
// Query using scope
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeActive).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query all tasks and filter with predicate
var allTasks []models.Task
testDB.Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsActive(&task) {
predicateResults = append(predicateResults, task)
}
}
// Compare results
if len(scopeResults) != len(predicateResults) {
t.Errorf("ScopeActive returned %d tasks, IsActive predicate returned %d tasks",
len(scopeResults), len(predicateResults))
}
// Should only have the active task
if len(scopeResults) != 1 {
t.Errorf("Expected 1 active task, got %d", len(scopeResults))
}
}
// TestScopeCompletedMatchesPredicate verifies ScopeCompleted produces same results as IsCompleted
func TestScopeCompletedMatchesPredicate(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
now := time.Now().UTC()
nextWeek := now.AddDate(0, 0, 7)
// Create tasks with different completion states
// Completed: NextDueDate nil AND has completions
completedTask := createTestTask(t, residenceID, &models.Task{
Title: "completed_task",
NextDueDate: nil,
IsCancelled: false,
IsArchived: false,
})
createTestCompletion(t, completedTask.ID)
// Not completed: has completions but NextDueDate set (recurring)
recurringTask := createTestTask(t, residenceID, &models.Task{
Title: "recurring_with_completion",
NextDueDate: timePtr(nextWeek),
IsCancelled: false,
IsArchived: false,
})
createTestCompletion(t, recurringTask.ID)
// Not completed: NextDueDate nil but no completions
createTestTask(t, residenceID, &models.Task{
Title: "no_completions",
NextDueDate: nil,
IsCancelled: false,
IsArchived: false,
})
// Not completed: has NextDueDate, no completions
createTestTask(t, residenceID, &models.Task{
Title: "pending_task",
NextDueDate: timePtr(nextWeek),
IsCancelled: false,
IsArchived: false,
})
// Query using scope
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeCompleted).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query all tasks with completions preloaded and filter with predicate
var allTasks []models.Task
testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsCompleted(&task) {
predicateResults = append(predicateResults, task)
}
}
// Compare results
if len(scopeResults) != len(predicateResults) {
t.Errorf("ScopeCompleted returned %d tasks, IsCompleted predicate returned %d tasks",
len(scopeResults), len(predicateResults))
}
// Should only have the completed task (nil NextDueDate + has completion)
if len(scopeResults) != 1 {
t.Errorf("Expected 1 completed task, got %d", len(scopeResults))
}
}
// TestScopeOverdueMatchesPredicate verifies ScopeOverdue produces same results as IsOverdue
func TestScopeOverdueMatchesPredicate(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
now := time.Now().UTC()
yesterday := now.AddDate(0, 0, -1)
tomorrow := now.AddDate(0, 0, 1)
// Overdue: NextDueDate in past, active, not completed
createTestTask(t, residenceID, &models.Task{
Title: "overdue_task",
NextDueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
})
// Overdue: DueDate in past (NextDueDate nil, no completions)
createTestTask(t, residenceID, &models.Task{
Title: "overdue_duedate",
NextDueDate: nil,
DueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
})
// Not overdue: future date
createTestTask(t, residenceID, &models.Task{
Title: "future_task",
NextDueDate: timePtr(tomorrow),
IsCancelled: false,
IsArchived: false,
})
// Not overdue: cancelled
createTestTask(t, residenceID, &models.Task{
Title: "cancelled_overdue",
NextDueDate: timePtr(yesterday),
IsCancelled: true,
IsArchived: false,
})
// Not overdue: completed (NextDueDate nil with completion)
completedTask := createTestTask(t, residenceID, &models.Task{
Title: "completed_past_due",
NextDueDate: nil,
DueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
})
createTestCompletion(t, completedTask.ID)
// Not overdue: no due date
createTestTask(t, residenceID, &models.Task{
Title: "no_due_date",
NextDueDate: nil,
DueDate: nil,
IsCancelled: false,
IsArchived: false,
})
// Query using scope
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query all tasks with completions preloaded and filter with predicate
var allTasks []models.Task
testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsOverdue(&task, now) {
predicateResults = append(predicateResults, task)
}
}
// Compare results
if len(scopeResults) != len(predicateResults) {
t.Errorf("ScopeOverdue returned %d tasks, IsOverdue predicate returned %d tasks",
len(scopeResults), len(predicateResults))
t.Logf("Scope results: %v", getTaskTitles(scopeResults))
t.Logf("Predicate results: %v", getTaskTitles(predicateResults))
}
// Should have 2 overdue tasks
if len(scopeResults) != 2 {
t.Errorf("Expected 2 overdue tasks, got %d", len(scopeResults))
}
}
// TestScopeOverdueWithSameDayTask tests the DATE vs TIMESTAMP comparison edge case
// This is a regression test for the bug where tasks due "today" were not counted as overdue
func TestScopeOverdueWithSameDayTask(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
// Create a task due at midnight today (simulating a DATE column)
now := time.Now().UTC()
todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
createTestTask(t, residenceID, &models.Task{
Title: "due_today_midnight",
NextDueDate: timePtr(todayMidnight),
IsCancelled: false,
IsArchived: false,
})
// Query using scope with current time (after midnight)
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query with predicate
var allTasks []models.Task
testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsOverdue(&task, now) {
predicateResults = append(predicateResults, task)
}
}
// Both should agree: if it's past midnight, the task due at midnight is overdue
if len(scopeResults) != len(predicateResults) {
t.Errorf("DATE vs TIMESTAMP mismatch! Scope returned %d, predicate returned %d",
len(scopeResults), len(predicateResults))
t.Logf("This indicates the PostgreSQL DATE/TIMESTAMP comparison bug may have returned")
}
// If current time is after midnight, task should be overdue
if now.After(todayMidnight) && len(scopeResults) != 1 {
t.Errorf("Task due at midnight should be overdue after midnight, got %d results", len(scopeResults))
}
}
// TestScopeDueSoonMatchesPredicate verifies ScopeDueSoon produces same results as IsDueSoon
func TestScopeDueSoonMatchesPredicate(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
now := time.Now().UTC()
yesterday := now.AddDate(0, 0, -1)
in5Days := now.AddDate(0, 0, 5)
in60Days := now.AddDate(0, 0, 60)
daysThreshold := 30
// Due soon: within threshold
createTestTask(t, residenceID, &models.Task{
Title: "due_soon",
NextDueDate: timePtr(in5Days),
IsCancelled: false,
IsArchived: false,
})
// Not due soon: beyond threshold
createTestTask(t, residenceID, &models.Task{
Title: "far_future",
NextDueDate: timePtr(in60Days),
IsCancelled: false,
IsArchived: false,
})
// Not due soon: overdue (in past)
createTestTask(t, residenceID, &models.Task{
Title: "overdue",
NextDueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
})
// Not due soon: cancelled
createTestTask(t, residenceID, &models.Task{
Title: "cancelled",
NextDueDate: timePtr(in5Days),
IsCancelled: true,
IsArchived: false,
})
// Query using scope
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeDueSoon(now, daysThreshold)).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query all tasks and filter with predicate
var allTasks []models.Task
testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsDueSoon(&task, now, daysThreshold) {
predicateResults = append(predicateResults, task)
}
}
// Compare results
if len(scopeResults) != len(predicateResults) {
t.Errorf("ScopeDueSoon returned %d tasks, IsDueSoon predicate returned %d tasks",
len(scopeResults), len(predicateResults))
}
// Should have 1 due soon task
if len(scopeResults) != 1 {
t.Errorf("Expected 1 due soon task, got %d", len(scopeResults))
}
}
// TestScopeUpcomingMatchesPredicate verifies ScopeUpcoming produces same results as IsUpcoming
func TestScopeUpcomingMatchesPredicate(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
now := time.Now().UTC()
in5Days := now.AddDate(0, 0, 5)
in60Days := now.AddDate(0, 0, 60)
daysThreshold := 30
// Upcoming: beyond threshold
createTestTask(t, residenceID, &models.Task{
Title: "far_future",
NextDueDate: timePtr(in60Days),
IsCancelled: false,
IsArchived: false,
})
// Upcoming: no due date
createTestTask(t, residenceID, &models.Task{
Title: "no_due_date",
NextDueDate: nil,
DueDate: nil,
IsCancelled: false,
IsArchived: false,
})
// Not upcoming: within due soon threshold
createTestTask(t, residenceID, &models.Task{
Title: "due_soon",
NextDueDate: timePtr(in5Days),
IsCancelled: false,
IsArchived: false,
})
// Not upcoming: cancelled
createTestTask(t, residenceID, &models.Task{
Title: "cancelled",
NextDueDate: timePtr(in60Days),
IsCancelled: true,
IsArchived: false,
})
// Query using scope
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeUpcoming(now, daysThreshold)).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query all tasks and filter with predicate
var allTasks []models.Task
testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsUpcoming(&task, now, daysThreshold) {
predicateResults = append(predicateResults, task)
}
}
// Compare results
if len(scopeResults) != len(predicateResults) {
t.Errorf("ScopeUpcoming returned %d tasks, IsUpcoming predicate returned %d tasks",
len(scopeResults), len(predicateResults))
}
// Should have 2 upcoming tasks
if len(scopeResults) != 2 {
t.Errorf("Expected 2 upcoming tasks, got %d", len(scopeResults))
}
}
// TestScopeInProgressMatchesPredicate verifies ScopeInProgress produces same results as IsInProgress
func TestScopeInProgressMatchesPredicate(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
// For InProgress, we need to use the exact status name "In Progress" because
// the scope joins on task_taskstatus.name = 'In Progress'
// First, try to find existing "In Progress" status, or create one
var inProgressStatus models.TaskStatus
if err := testDB.Where("name = ?", "In Progress").First(&inProgressStatus).Error; err != nil {
// Create it if it doesn't exist
inProgressStatus = models.TaskStatus{Name: "In Progress"}
testDB.Create(&inProgressStatus)
}
var pendingStatus models.TaskStatus
if err := testDB.Where("name = ?", "Pending").First(&pendingStatus).Error; err != nil {
pendingStatus = models.TaskStatus{Name: "Pending"}
testDB.Create(&pendingStatus)
}
defer cleanupTestData()
// In progress task
createTestTask(t, residenceID, &models.Task{
Title: "in_progress",
StatusID: &inProgressStatus.ID,
})
// Not in progress: different status
createTestTask(t, residenceID, &models.Task{
Title: "pending",
StatusID: &pendingStatus.ID,
})
// Not in progress: no status
createTestTask(t, residenceID, &models.Task{
Title: "no_status",
StatusID: nil,
})
// Query using scope
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeInProgress).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query all tasks with status preloaded and filter with predicate
var allTasks []models.Task
testDB.Preload("Status").Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsInProgress(&task) {
predicateResults = append(predicateResults, task)
}
}
// Compare results
if len(scopeResults) != len(predicateResults) {
t.Errorf("ScopeInProgress returned %d tasks, IsInProgress predicate returned %d tasks",
len(scopeResults), len(predicateResults))
}
// Should have 1 in progress task
if len(scopeResults) != 1 {
t.Errorf("Expected 1 in progress task, got %d", len(scopeResults))
}
}
// TestScopeForResidences verifies filtering by multiple residence IDs
func TestScopeForResidences(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID1 := createTestResidence(t)
residenceID2 := createTestResidence(t)
residenceID3 := createTestResidence(t)
defer cleanupTestData()
// Create tasks in different residences
createTestTask(t, residenceID1, &models.Task{Title: "task_r1"})
createTestTask(t, residenceID2, &models.Task{Title: "task_r2"})
createTestTask(t, residenceID3, &models.Task{Title: "task_r3"})
// Query for residences 1 and 2 only
var results []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidences([]uint{residenceID1, residenceID2})).
Find(&results).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
if len(results) != 2 {
t.Errorf("Expected 2 tasks from residences 1 and 2, got %d", len(results))
}
// Verify empty slice returns no results
var emptyResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidences([]uint{})).
Find(&emptyResults)
if len(emptyResults) != 0 {
t.Errorf("Expected 0 tasks for empty residence list, got %d", len(emptyResults))
}
}
// Helper to get task titles for debugging
func getTaskTitles(tasks []models.Task) []string {
titles := make([]string, len(tasks))
for i, task := range tasks {
titles[i] = task.Title
}
return titles
}

259
internal/task/task.go Normal file
View File

@@ -0,0 +1,259 @@
// Package task provides consolidated task domain logic.
//
// This package serves as the single entry point for all task-related business logic.
// It re-exports functions from sub-packages for convenient imports.
//
// Architecture:
//
// predicates/ - Pure Go predicate functions (SINGLE SOURCE OF TRUTH)
// scopes/ - GORM scope functions (SQL mirrors of predicates)
// categorization/ - Chain of Responsibility for kanban categorization
//
// Usage:
//
// import "github.com/treytartt/casera-api/internal/task"
//
// // Use predicates for in-memory checks
// if task.IsCompleted(myTask) { ... }
//
// // Use scopes for database queries
// db.Scopes(task.ScopeOverdue(now)).Find(&tasks)
//
// // Use categorization for kanban column determination
// column := task.CategorizeTask(myTask, 30)
//
// For more details, see docs/TASK_LOGIC_ARCHITECTURE.md
package task
import (
"time"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/task/categorization"
"github.com/treytartt/casera-api/internal/task/predicates"
"github.com/treytartt/casera-api/internal/task/scopes"
"gorm.io/gorm"
)
// =============================================================================
// RE-EXPORTED TYPES
// =============================================================================
// KanbanColumn represents the possible kanban column names
type KanbanColumn = categorization.KanbanColumn
// Column constants
const (
ColumnOverdue = categorization.ColumnOverdue
ColumnDueSoon = categorization.ColumnDueSoon
ColumnUpcoming = categorization.ColumnUpcoming
ColumnInProgress = categorization.ColumnInProgress
ColumnCompleted = categorization.ColumnCompleted
ColumnCancelled = categorization.ColumnCancelled
)
// =============================================================================
// RE-EXPORTED PREDICATES
// These are the SINGLE SOURCE OF TRUTH for task logic
// =============================================================================
// IsCompleted returns true if a task is considered "completed" per kanban rules.
// A task is completed when NextDueDate is nil AND has at least one completion.
func IsCompleted(task *models.Task) bool {
return predicates.IsCompleted(task)
}
// IsActive returns true if the task is not cancelled and not archived.
func IsActive(task *models.Task) bool {
return predicates.IsActive(task)
}
// IsCancelled returns true if the task has been cancelled.
func IsCancelled(task *models.Task) bool {
return predicates.IsCancelled(task)
}
// IsArchived returns true if the task has been archived.
func IsArchived(task *models.Task) bool {
return predicates.IsArchived(task)
}
// IsInProgress returns true if the task has status "In Progress".
func IsInProgress(task *models.Task) bool {
return predicates.IsInProgress(task)
}
// EffectiveDate returns the date used for scheduling calculations.
// Prefers NextDueDate, falls back to DueDate.
func EffectiveDate(task *models.Task) *time.Time {
return predicates.EffectiveDate(task)
}
// IsOverdue returns true if the task's effective date is in the past.
func IsOverdue(task *models.Task, now time.Time) bool {
return predicates.IsOverdue(task, now)
}
// IsDueSoon returns true if the task's effective date is within the threshold.
func IsDueSoon(task *models.Task, now time.Time, daysThreshold int) bool {
return predicates.IsDueSoon(task, now, daysThreshold)
}
// IsUpcoming returns true if the task is due after the threshold or has no due date.
func IsUpcoming(task *models.Task, now time.Time, daysThreshold int) bool {
return predicates.IsUpcoming(task, now, daysThreshold)
}
// HasCompletions returns true if the task has at least one completion record.
func HasCompletions(task *models.Task) bool {
return predicates.HasCompletions(task)
}
// CompletionCount returns the number of completions for a task.
func CompletionCount(task *models.Task) int {
return predicates.CompletionCount(task)
}
// IsRecurring returns true if the task has a recurring frequency.
func IsRecurring(task *models.Task) bool {
return predicates.IsRecurring(task)
}
// IsOneTime returns true if the task is a one-time (non-recurring) task.
func IsOneTime(task *models.Task) bool {
return predicates.IsOneTime(task)
}
// =============================================================================
// RE-EXPORTED SCOPES
// These are SQL mirrors of the predicates for database queries
// =============================================================================
// ScopeActive filters to tasks that are not cancelled and not archived.
func ScopeActive(db *gorm.DB) *gorm.DB {
return scopes.ScopeActive(db)
}
// ScopeCancelled filters to cancelled tasks only.
func ScopeCancelled(db *gorm.DB) *gorm.DB {
return scopes.ScopeCancelled(db)
}
// ScopeArchived filters to archived tasks only.
func ScopeArchived(db *gorm.DB) *gorm.DB {
return scopes.ScopeArchived(db)
}
// ScopeCompleted filters to completed tasks.
func ScopeCompleted(db *gorm.DB) *gorm.DB {
return scopes.ScopeCompleted(db)
}
// ScopeNotCompleted excludes completed tasks.
func ScopeNotCompleted(db *gorm.DB) *gorm.DB {
return scopes.ScopeNotCompleted(db)
}
// ScopeInProgress filters to tasks with status "In Progress".
func ScopeInProgress(db *gorm.DB) *gorm.DB {
return scopes.ScopeInProgress(db)
}
// ScopeNotInProgress excludes tasks with status "In Progress".
func ScopeNotInProgress(db *gorm.DB) *gorm.DB {
return scopes.ScopeNotInProgress(db)
}
// ScopeOverdue returns a scope for overdue tasks.
func ScopeOverdue(now time.Time) func(db *gorm.DB) *gorm.DB {
return scopes.ScopeOverdue(now)
}
// ScopeDueSoon returns a scope for tasks due within the threshold.
func ScopeDueSoon(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB {
return scopes.ScopeDueSoon(now, daysThreshold)
}
// ScopeUpcoming returns a scope for tasks due after the threshold or with no due date.
func ScopeUpcoming(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB {
return scopes.ScopeUpcoming(now, daysThreshold)
}
// ScopeDueInRange returns a scope for tasks with effective date in a range.
func ScopeDueInRange(start, end time.Time) func(db *gorm.DB) *gorm.DB {
return scopes.ScopeDueInRange(start, end)
}
// ScopeHasDueDate filters to tasks that have an effective due date.
func ScopeHasDueDate(db *gorm.DB) *gorm.DB {
return scopes.ScopeHasDueDate(db)
}
// ScopeNoDueDate filters to tasks that have no effective due date.
func ScopeNoDueDate(db *gorm.DB) *gorm.DB {
return scopes.ScopeNoDueDate(db)
}
// ScopeForResidence filters tasks by a single residence ID.
func ScopeForResidence(residenceID uint) func(db *gorm.DB) *gorm.DB {
return scopes.ScopeForResidence(residenceID)
}
// ScopeForResidences filters tasks by multiple residence IDs.
func ScopeForResidences(residenceIDs []uint) func(db *gorm.DB) *gorm.DB {
return scopes.ScopeForResidences(residenceIDs)
}
// ScopeHasCompletions filters to tasks that have at least one completion.
func ScopeHasCompletions(db *gorm.DB) *gorm.DB {
return scopes.ScopeHasCompletions(db)
}
// ScopeNoCompletions filters to tasks that have no completions.
func ScopeNoCompletions(db *gorm.DB) *gorm.DB {
return scopes.ScopeNoCompletions(db)
}
// ScopeOrderByDueDate orders tasks by effective due date ascending, nulls last.
func ScopeOrderByDueDate(db *gorm.DB) *gorm.DB {
return scopes.ScopeOrderByDueDate(db)
}
// ScopeOrderByPriority orders tasks by priority level descending (urgent first).
func ScopeOrderByPriority(db *gorm.DB) *gorm.DB {
return scopes.ScopeOrderByPriority(db)
}
// ScopeOrderByCreatedAt orders tasks by creation date descending (newest first).
func ScopeOrderByCreatedAt(db *gorm.DB) *gorm.DB {
return scopes.ScopeOrderByCreatedAt(db)
}
// ScopeKanbanOrder applies the standard kanban ordering.
func ScopeKanbanOrder(db *gorm.DB) *gorm.DB {
return scopes.ScopeKanbanOrder(db)
}
// =============================================================================
// RE-EXPORTED CATEGORIZATION
// =============================================================================
// CategorizeTask determines which kanban column a task belongs to.
func CategorizeTask(task *models.Task, daysThreshold int) KanbanColumn {
return categorization.CategorizeTask(task, daysThreshold)
}
// DetermineKanbanColumn is a convenience function that returns the column as a string.
func DetermineKanbanColumn(task *models.Task, daysThreshold int) string {
return categorization.DetermineKanbanColumn(task, daysThreshold)
}
// CategorizeTasksIntoColumns categorizes multiple tasks into their respective columns.
func CategorizeTasksIntoColumns(tasks []models.Task, daysThreshold int) map[KanbanColumn][]models.Task {
return categorization.CategorizeTasksIntoColumns(tasks, daysThreshold)
}
// NewChain creates a new categorization chain for custom usage.
func NewChain() *categorization.Chain {
return categorization.NewChain()
}

View File

@@ -101,16 +101,16 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
log.Info().Int("eligible_users", len(eligibleUserIDs)).Msg("Found users eligible for task reminders this hour")
// Step 2: Query tasks due today or tomorrow only for eligible users
// A task is considered "completed" (and should be excluded) if:
// - NextDueDate IS NULL AND it has at least one completion record
// This matches the kanban categorization logic
// Completion detection logic matches internal/task/predicates.IsCompleted:
// A task is "completed" when NextDueDate == nil AND has at least one completion.
// See internal/task/scopes.ScopeNotCompleted for the SQL equivalent.
var dueSoonTasks []models.Task
err = h.db.Preload("Status").Preload("Completions").Preload("Residence").
Where("(due_date >= ? AND due_date < ?) OR (next_due_date >= ? AND next_due_date < ?)",
today, dayAfterTomorrow, today, dayAfterTomorrow).
Where("is_cancelled = false").
Where("is_archived = false").
// Exclude completed tasks: tasks with no next_due_date AND at least one completion
// Exclude completed tasks (matches scopes.ScopeNotCompleted)
Where("NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))").
Where("(assigned_to_id IN ? OR residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ?))",
eligibleUserIDs, eligibleUserIDs).
@@ -218,15 +218,15 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
log.Info().Int("eligible_users", len(eligibleUserIDs)).Msg("Found users eligible for overdue reminders this hour")
// Step 2: Query overdue tasks only for eligible users
// A task is considered "completed" (and should be excluded) if:
// - NextDueDate IS NULL AND it has at least one completion record
// This matches the kanban categorization logic
// Completion detection logic matches internal/task/predicates.IsCompleted:
// A task is "completed" when NextDueDate == nil AND has at least one completion.
// See internal/task/scopes.ScopeNotCompleted for the SQL equivalent.
var overdueTasks []models.Task
err = h.db.Preload("Status").Preload("Completions").Preload("Residence").
Where("due_date < ? OR next_due_date < ?", today, today).
Where("is_cancelled = false").
Where("is_archived = false").
// Exclude completed tasks: tasks with no next_due_date AND at least one completion
// Exclude completed tasks (matches scopes.ScopeNotCompleted)
Where("NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))").
Where("(assigned_to_id IN ? OR residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ?))",
eligibleUserIDs, eligibleUserIDs).
@@ -299,6 +299,9 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error
nextWeek := today.AddDate(0, 0, 7)
// Get all users with their task statistics
// Completion detection logic matches internal/task/predicates.IsCompleted:
// A task is "completed" when NextDueDate == nil AND has at least one completion.
// We use COALESCE(next_due_date, due_date) as the effective date for categorization.
var userStats []struct {
UserID uint
TotalTasks int
@@ -310,8 +313,16 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error
SELECT
u.id as user_id,
COUNT(DISTINCT t.id) as total_tasks,
COUNT(DISTINCT CASE WHEN t.due_date < ? AND tc.id IS NULL THEN t.id END) as overdue_tasks,
COUNT(DISTINCT CASE WHEN t.due_date >= ? AND t.due_date < ? AND tc.id IS NULL THEN t.id END) as due_this_week
COUNT(DISTINCT CASE
WHEN COALESCE(t.next_due_date, t.due_date) < ?
AND NOT (t.next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = t.id))
THEN t.id
END) as overdue_tasks,
COUNT(DISTINCT CASE
WHEN COALESCE(t.next_due_date, t.due_date) >= ? AND COALESCE(t.next_due_date, t.due_date) < ?
AND NOT (t.next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = t.id))
THEN t.id
END) as due_this_week
FROM auth_user u
JOIN residence_residence r ON r.owner_id = u.id OR r.id IN (
SELECT residence_id FROM residence_residence_users WHERE user_id = u.id
@@ -319,7 +330,6 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error
JOIN task_task t ON t.residence_id = r.id
AND t.is_cancelled = false
AND t.is_archived = false
LEFT JOIN task_taskcompletion tc ON t.id = tc.task_id
WHERE u.is_active = true
GROUP BY u.id
HAVING COUNT(DISTINCT t.id) > 0