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:
369
docs/TASK_LOGIC_ARCHITECTURE.md
Normal file
369
docs/TASK_LOGIC_ARCHITECTURE.md
Normal 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
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/treytartt/casera-api/internal/models"
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
|
"github.com/treytartt/casera-api/internal/task/scopes"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminDashboardHandler handles admin dashboard endpoints
|
// 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("is_active = ?", true).Count(&stats.Residences.Active)
|
||||||
h.db.Model(&models.Residence{}).Where("created_at >= ?", thirtyDaysAgo).Count(&stats.Residences.New30d)
|
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{}).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{}).Scopes(scopes.ScopeActive).Count(&stats.Tasks.Active)
|
||||||
h.db.Model(&models.Task{}).Where("is_archived = ?", true).Count(&stats.Tasks.Archived)
|
h.db.Model(&models.Task{}).Scopes(scopes.ScopeArchived).Count(&stats.Tasks.Archived)
|
||||||
h.db.Model(&models.Task{}).Where("is_cancelled = ?", true).Count(&stats.Tasks.Cancelled)
|
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)
|
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)
|
// 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{}).
|
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").
|
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").
|
Where("LOWER(task_taskstatus.name) = ? OR task_taskstatus.id IS NULL", "pending").
|
||||||
Count(&stats.Tasks.Pending)
|
Count(&stats.Tasks.Pending)
|
||||||
|
|
||||||
h.db.Model(&models.Task{}).
|
h.db.Model(&models.Task{}).
|
||||||
Where("is_cancelled = ? AND is_archived = ?", false, false).
|
Scopes(scopes.ScopeActive, scopes.ScopeInProgress).
|
||||||
Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
|
|
||||||
Where("LOWER(task_taskstatus.name) = ?", "in progress").
|
|
||||||
Count(&stats.Tasks.InProgress)
|
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{}).
|
h.db.Model(&models.Task{}).
|
||||||
Where("is_cancelled = ? AND is_archived = ?", false, false).
|
Scopes(scopes.ScopeActive, scopes.ScopeCompleted).
|
||||||
Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
|
|
||||||
Where("LOWER(task_taskstatus.name) = ?", "completed").
|
|
||||||
Count(&stats.Tasks.Completed)
|
Count(&stats.Tasks.Completed)
|
||||||
|
|
||||||
h.db.Model(&models.Task{}).
|
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").
|
Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
|
||||||
Where("LOWER(task_taskstatus.name) = ?", "on hold").
|
Where("LOWER(task_taskstatus.name) = ?", "on hold").
|
||||||
Count(&stats.Tasks.OnHold)
|
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{}).
|
h.db.Model(&models.Task{}).
|
||||||
Where("next_due_date < ? AND is_cancelled = ? AND is_archived = ?", now, false, false).
|
Scopes(scopes.ScopeOverdue(now)).
|
||||||
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"}).
|
|
||||||
Count(&stats.Tasks.Overdue)
|
Count(&stats.Tasks.Overdue)
|
||||||
|
|
||||||
// Contractor stats
|
// Contractor stats
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
|
|
||||||
"github.com/treytartt/casera-api/internal/models"
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
|
"github.com/treytartt/casera-api/internal/task/categorization"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TaskCategoryResponse represents a task category
|
// 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.
|
// DetermineKanbanColumn determines which kanban column a task belongs to.
|
||||||
// This is a wrapper around the Chain of Responsibility implementation in
|
// Delegates to internal/task/categorization package which is the single source
|
||||||
// internal/task/categorization package. See that package for detailed
|
// of truth for task categorization logic.
|
||||||
// documentation on the categorization logic.
|
|
||||||
//
|
|
||||||
// Deprecated: Use categorization.DetermineKanbanColumn directly for new code.
|
|
||||||
func DetermineKanbanColumn(task *models.Task, daysThreshold int) string {
|
func DetermineKanbanColumn(task *models.Task, daysThreshold int) string {
|
||||||
// Import would cause circular dependency, so we replicate the logic here
|
return categorization.DetermineKanbanColumn(task, daysThreshold)
|
||||||
// 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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,28 +110,66 @@ func (Task) TableName() string {
|
|||||||
return "task_task"
|
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 {
|
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
|
return false
|
||||||
}
|
}
|
||||||
// Check if there's a completion
|
// Completed check: NextDueDate == nil AND has completions
|
||||||
if len(t.Completions) > 0 {
|
if t.NextDueDate == nil && len(t.Completions) > 0 {
|
||||||
return false
|
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 {
|
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
|
return false
|
||||||
}
|
}
|
||||||
if len(t.Completions) > 0 {
|
// Completed check: NextDueDate == nil AND has completions
|
||||||
|
if t.NextDueDate == nil && len(t.Completions) > 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
threshold := time.Now().UTC().AddDate(0, 0, days)
|
// Effective date: NextDueDate ?? DueDate
|
||||||
return t.DueDate.Before(threshold) && !t.IsOverdue()
|
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
|
// TaskCompletion represents the task_taskcompletion table
|
||||||
|
|||||||
@@ -6,16 +6,10 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/treytartt/casera-api/internal/models"
|
"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
|
// TaskRepository handles database operations for tasks
|
||||||
type TaskRepository struct {
|
type TaskRepository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
@@ -141,6 +135,7 @@ func (r *TaskRepository) Unarchive(id uint) error {
|
|||||||
// === Kanban Board ===
|
// === Kanban Board ===
|
||||||
|
|
||||||
// GetKanbanData retrieves tasks organized for kanban display
|
// 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) {
|
func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*models.KanbanBoard, error) {
|
||||||
var tasks []models.Task
|
var tasks []models.Task
|
||||||
err := r.db.Preload("CreatedBy").
|
err := r.db.Preload("CreatedBy").
|
||||||
@@ -153,120 +148,69 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo
|
|||||||
Preload("Completions.Images").
|
Preload("Completions.Images").
|
||||||
Preload("Completions.CompletedBy").
|
Preload("Completions.CompletedBy").
|
||||||
Where("residence_id = ? AND is_archived = ?", residenceID, false).
|
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
|
Find(&tasks).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Organize into columns
|
// Use the categorization package as the single source of truth
|
||||||
now := time.Now().UTC()
|
categorized := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
columns := []models.KanbanColumn{
|
columns := []models.KanbanColumn{
|
||||||
{
|
{
|
||||||
Name: "overdue_tasks",
|
Name: string(categorization.ColumnOverdue),
|
||||||
DisplayName: "Overdue",
|
DisplayName: "Overdue",
|
||||||
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
|
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
|
||||||
Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"},
|
Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"},
|
||||||
Color: "#FF3B30",
|
Color: "#FF3B30",
|
||||||
Tasks: overdue,
|
Tasks: categorized[categorization.ColumnOverdue],
|
||||||
Count: len(overdue),
|
Count: len(categorized[categorization.ColumnOverdue]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "in_progress_tasks",
|
Name: string(categorization.ColumnInProgress),
|
||||||
DisplayName: "In Progress",
|
DisplayName: "In Progress",
|
||||||
ButtonTypes: []string{"edit", "complete", "cancel"},
|
ButtonTypes: []string{"edit", "complete", "cancel"},
|
||||||
Icons: map[string]string{"ios": "hammer", "android": "Build"},
|
Icons: map[string]string{"ios": "hammer", "android": "Build"},
|
||||||
Color: "#5856D6",
|
Color: "#5856D6",
|
||||||
Tasks: inProgress,
|
Tasks: categorized[categorization.ColumnInProgress],
|
||||||
Count: len(inProgress),
|
Count: len(categorized[categorization.ColumnInProgress]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "due_soon_tasks",
|
Name: string(categorization.ColumnDueSoon),
|
||||||
DisplayName: "Due Soon",
|
DisplayName: "Due Soon",
|
||||||
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
|
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
|
||||||
Icons: map[string]string{"ios": "clock", "android": "Schedule"},
|
Icons: map[string]string{"ios": "clock", "android": "Schedule"},
|
||||||
Color: "#FF9500",
|
Color: "#FF9500",
|
||||||
Tasks: dueSoon,
|
Tasks: categorized[categorization.ColumnDueSoon],
|
||||||
Count: len(dueSoon),
|
Count: len(categorized[categorization.ColumnDueSoon]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "upcoming_tasks",
|
Name: string(categorization.ColumnUpcoming),
|
||||||
DisplayName: "Upcoming",
|
DisplayName: "Upcoming",
|
||||||
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
|
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
|
||||||
Icons: map[string]string{"ios": "calendar", "android": "Event"},
|
Icons: map[string]string{"ios": "calendar", "android": "Event"},
|
||||||
Color: "#007AFF",
|
Color: "#007AFF",
|
||||||
Tasks: upcoming,
|
Tasks: categorized[categorization.ColumnUpcoming],
|
||||||
Count: len(upcoming),
|
Count: len(categorized[categorization.ColumnUpcoming]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "completed_tasks",
|
Name: string(categorization.ColumnCompleted),
|
||||||
DisplayName: "Completed",
|
DisplayName: "Completed",
|
||||||
ButtonTypes: []string{},
|
ButtonTypes: []string{},
|
||||||
Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"},
|
Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"},
|
||||||
Color: "#34C759",
|
Color: "#34C759",
|
||||||
Tasks: completed,
|
Tasks: categorized[categorization.ColumnCompleted],
|
||||||
Count: len(completed),
|
Count: len(categorized[categorization.ColumnCompleted]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "cancelled_tasks",
|
Name: string(categorization.ColumnCancelled),
|
||||||
DisplayName: "Cancelled",
|
DisplayName: "Cancelled",
|
||||||
ButtonTypes: []string{"uncancel", "delete"},
|
ButtonTypes: []string{"uncancel", "delete"},
|
||||||
Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"},
|
Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"},
|
||||||
Color: "#8E8E93",
|
Color: "#8E8E93",
|
||||||
Tasks: cancelled,
|
Tasks: categorized[categorization.ColumnCancelled],
|
||||||
Count: len(cancelled),
|
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
|
// 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) {
|
func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, daysThreshold int) (*models.KanbanBoard, error) {
|
||||||
var tasks []models.Task
|
var tasks []models.Task
|
||||||
err := r.db.Preload("CreatedBy").
|
err := r.db.Preload("CreatedBy").
|
||||||
@@ -291,120 +236,69 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint,
|
|||||||
Preload("Completions.CompletedBy").
|
Preload("Completions.CompletedBy").
|
||||||
Preload("Residence").
|
Preload("Residence").
|
||||||
Where("residence_id IN ? AND is_archived = ?", residenceIDs, false).
|
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
|
Find(&tasks).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Organize into columns
|
// Use the categorization package as the single source of truth
|
||||||
now := time.Now().UTC()
|
categorized := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
columns := []models.KanbanColumn{
|
columns := []models.KanbanColumn{
|
||||||
{
|
{
|
||||||
Name: "overdue_tasks",
|
Name: string(categorization.ColumnOverdue),
|
||||||
DisplayName: "Overdue",
|
DisplayName: "Overdue",
|
||||||
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
|
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
|
||||||
Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"},
|
Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"},
|
||||||
Color: "#FF3B30",
|
Color: "#FF3B30",
|
||||||
Tasks: overdue,
|
Tasks: categorized[categorization.ColumnOverdue],
|
||||||
Count: len(overdue),
|
Count: len(categorized[categorization.ColumnOverdue]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "in_progress_tasks",
|
Name: string(categorization.ColumnInProgress),
|
||||||
DisplayName: "In Progress",
|
DisplayName: "In Progress",
|
||||||
ButtonTypes: []string{"edit", "complete", "cancel"},
|
ButtonTypes: []string{"edit", "complete", "cancel"},
|
||||||
Icons: map[string]string{"ios": "hammer", "android": "Build"},
|
Icons: map[string]string{"ios": "hammer", "android": "Build"},
|
||||||
Color: "#5856D6",
|
Color: "#5856D6",
|
||||||
Tasks: inProgress,
|
Tasks: categorized[categorization.ColumnInProgress],
|
||||||
Count: len(inProgress),
|
Count: len(categorized[categorization.ColumnInProgress]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "due_soon_tasks",
|
Name: string(categorization.ColumnDueSoon),
|
||||||
DisplayName: "Due Soon",
|
DisplayName: "Due Soon",
|
||||||
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
|
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
|
||||||
Icons: map[string]string{"ios": "clock", "android": "Schedule"},
|
Icons: map[string]string{"ios": "clock", "android": "Schedule"},
|
||||||
Color: "#FF9500",
|
Color: "#FF9500",
|
||||||
Tasks: dueSoon,
|
Tasks: categorized[categorization.ColumnDueSoon],
|
||||||
Count: len(dueSoon),
|
Count: len(categorized[categorization.ColumnDueSoon]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "upcoming_tasks",
|
Name: string(categorization.ColumnUpcoming),
|
||||||
DisplayName: "Upcoming",
|
DisplayName: "Upcoming",
|
||||||
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
|
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
|
||||||
Icons: map[string]string{"ios": "calendar", "android": "Event"},
|
Icons: map[string]string{"ios": "calendar", "android": "Event"},
|
||||||
Color: "#007AFF",
|
Color: "#007AFF",
|
||||||
Tasks: upcoming,
|
Tasks: categorized[categorization.ColumnUpcoming],
|
||||||
Count: len(upcoming),
|
Count: len(categorized[categorization.ColumnUpcoming]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "completed_tasks",
|
Name: string(categorization.ColumnCompleted),
|
||||||
DisplayName: "Completed",
|
DisplayName: "Completed",
|
||||||
ButtonTypes: []string{},
|
ButtonTypes: []string{},
|
||||||
Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"},
|
Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"},
|
||||||
Color: "#34C759",
|
Color: "#34C759",
|
||||||
Tasks: completed,
|
Tasks: categorized[categorization.ColumnCompleted],
|
||||||
Count: len(completed),
|
Count: len(categorized[categorization.ColumnCompleted]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "cancelled_tasks",
|
Name: string(categorization.ColumnCancelled),
|
||||||
DisplayName: "Cancelled",
|
DisplayName: "Cancelled",
|
||||||
ButtonTypes: []string{"uncancel", "delete"},
|
ButtonTypes: []string{"uncancel", "delete"},
|
||||||
Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"},
|
Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"},
|
||||||
Color: "#8E8E93",
|
Color: "#8E8E93",
|
||||||
Tasks: cancelled,
|
Tasks: categorized[categorization.ColumnCancelled],
|
||||||
Count: len(cancelled),
|
Count: len(categorized[categorization.ColumnCancelled]),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,62 +438,57 @@ type TaskStatistics struct {
|
|||||||
TasksDueNextMonth int
|
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) {
|
func (r *TaskRepository) GetTaskStatistics(residenceIDs []uint) (*TaskStatistics, error) {
|
||||||
if len(residenceIDs) == 0 {
|
if len(residenceIDs) == 0 {
|
||||||
return &TaskStatistics{}, nil
|
return &TaskStatistics{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
nextWeek := now.AddDate(0, 0, 7)
|
|
||||||
nextMonth := now.AddDate(0, 1, 0)
|
|
||||||
|
|
||||||
var totalTasks, totalOverdue, totalPending, tasksDueNextWeek, tasksDueNextMonth int64
|
var totalTasks, totalOverdue, totalPending, tasksDueNextWeek, tasksDueNextMonth int64
|
||||||
|
|
||||||
// Count total active tasks (not cancelled, not archived)
|
// Count total active tasks (not cancelled, not archived)
|
||||||
|
// Uses: task.ScopeActive, task.ScopeForResidences
|
||||||
err := r.db.Model(&models.Task{}).
|
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
|
Count(&totalTasks).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count overdue tasks: due_date or next_due_date < now, and NOT completed
|
// Count overdue tasks using consistent scope
|
||||||
// A task is "completed" if next_due_date IS NULL AND has at least one completion
|
// Uses: task.ScopeOverdue (which includes ScopeActive and ScopeNotCompleted)
|
||||||
err = r.db.Model(&models.Task{}).
|
err = r.db.Model(&models.Task{}).
|
||||||
Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false).
|
Scopes(task.ScopeForResidences(residenceIDs), task.ScopeOverdue(now)).
|
||||||
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))").
|
|
||||||
Count(&totalOverdue).Error
|
Count(&totalOverdue).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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{}).
|
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, task.ScopeNotCompleted).
|
||||||
Where("id NOT IN (?)", r.db.Table("task_taskcompletion").Select("task_id")).
|
|
||||||
Count(&totalPending).Error
|
Count(&totalPending).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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{}).
|
err = r.db.Model(&models.Task{}).
|
||||||
Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false).
|
Scopes(task.ScopeForResidences(residenceIDs), task.ScopeDueSoon(now, 7)).
|
||||||
Where("due_date >= ? AND due_date < ?", now, nextWeek).
|
|
||||||
Where("id NOT IN (?)", r.db.Table("task_taskcompletion").Select("task_id")).
|
|
||||||
Count(&tasksDueNextWeek).Error
|
Count(&tasksDueNextWeek).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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{}).
|
err = r.db.Model(&models.Task{}).
|
||||||
Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false).
|
Scopes(task.ScopeForResidences(residenceIDs), task.ScopeDueSoon(now, 30)).
|
||||||
Where("due_date >= ? AND due_date < ?", now, nextMonth).
|
|
||||||
Where("id NOT IN (?)", r.db.Table("task_taskcompletion").Select("task_id")).
|
|
||||||
Count(&tasksDueNextMonth).Error
|
Count(&tasksDueNextMonth).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/treytartt/casera-api/internal/dto/responses"
|
"github.com/treytartt/casera-api/internal/dto/responses"
|
||||||
"github.com/treytartt/casera-api/internal/models"
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
"github.com/treytartt/casera-api/internal/repositories"
|
"github.com/treytartt/casera-api/internal/repositories"
|
||||||
|
"github.com/treytartt/casera-api/internal/task/predicates"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Common errors
|
// Common errors
|
||||||
@@ -553,8 +554,9 @@ func (s *ResidenceService) GenerateTasksReport(residenceID, userID uint) (*Tasks
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i, task := range tasks {
|
for i, task := range tasks {
|
||||||
// Determine if task is completed (has completions)
|
// Use predicates from internal/task/predicates as single source of truth
|
||||||
isCompleted := len(task.Completions) > 0
|
isCompleted := predicates.IsCompleted(&task)
|
||||||
|
isOverdue := predicates.IsOverdue(&task, now)
|
||||||
|
|
||||||
taskData := TaskReportData{
|
taskData := TaskReportData{
|
||||||
ID: task.ID,
|
ID: task.ID,
|
||||||
@@ -574,17 +576,19 @@ func (s *ResidenceService) GenerateTasksReport(residenceID, userID uint) (*Tasks
|
|||||||
if task.Status != nil {
|
if task.Status != nil {
|
||||||
taskData.Status = task.Status.Name
|
taskData.Status = task.Status.Name
|
||||||
}
|
}
|
||||||
if task.DueDate != nil {
|
// Use effective date for report (NextDueDate ?? DueDate)
|
||||||
taskData.DueDate = task.DueDate
|
effectiveDate := predicates.EffectiveDate(&task)
|
||||||
|
if effectiveDate != nil {
|
||||||
|
taskData.DueDate = effectiveDate
|
||||||
}
|
}
|
||||||
|
|
||||||
report.Tasks[i] = taskData
|
report.Tasks[i] = taskData
|
||||||
|
|
||||||
if isCompleted {
|
if isCompleted {
|
||||||
report.Completed++
|
report.Completed++
|
||||||
} else if !task.IsCancelled && !task.IsArchived {
|
} else if predicates.IsActive(&task) {
|
||||||
report.Pending++
|
report.Pending++
|
||||||
if task.DueDate != nil && task.DueDate.Before(now) {
|
if isOverdue {
|
||||||
report.Overdue++
|
report.Overdue++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/treytartt/casera-api/internal/models"
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
|
"github.com/treytartt/casera-api/internal/task/predicates"
|
||||||
)
|
)
|
||||||
|
|
||||||
// iOS Notification Category Identifiers
|
// iOS Notification Category Identifiers
|
||||||
@@ -15,65 +16,52 @@ const (
|
|||||||
IOSCategoryTaskGeneric = "TASK_NOTIFICATION_GENERIC" // non-premium users
|
IOSCategoryTaskGeneric = "TASK_NOTIFICATION_GENERIC" // non-premium users
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetButtonTypesForTask returns the appropriate button_types for a task
|
// GetButtonTypesForTask returns the appropriate button_types for a task.
|
||||||
// This reuses the same categorization logic as GetKanbanData in task_repo.go
|
// 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 {
|
func GetButtonTypesForTask(task *models.Task, daysThreshold int) []string {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
threshold := now.AddDate(0, 0, daysThreshold)
|
|
||||||
|
|
||||||
// Priority order matches kanban logic
|
// Priority order matches kanban logic (see categorization/chain.go)
|
||||||
if task.IsCancelled {
|
// 1. Cancelled
|
||||||
|
if predicates.IsCancelled(task) {
|
||||||
return []string{"uncancel", "delete"}
|
return []string{"uncancel", "delete"}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if task is "completed" (one-time task with nil next_due_date)
|
// 2. Completed (one-time task with nil next_due_date and has completions)
|
||||||
if isTaskCompleted(task) {
|
if predicates.IsCompleted(task) {
|
||||||
return []string{} // read-only
|
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"}
|
return []string{"edit", "complete", "cancel"}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use next_due_date for categorization (handles recurring tasks properly)
|
// 4. Overdue
|
||||||
if task.NextDueDate != nil {
|
if predicates.IsOverdue(task, now) {
|
||||||
if task.NextDueDate.Before(now) {
|
return []string{"edit", "complete", "cancel", "mark_in_progress"}
|
||||||
// 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"}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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"}
|
return []string{"edit", "complete", "cancel", "mark_in_progress"}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isTaskCompleted determines if a task should be considered "completed" for kanban display.
|
// GetIOSCategoryForTask returns the iOS notification category identifier.
|
||||||
// A task is completed if next_due_date is nil (meaning it was a one-time task that's been completed).
|
// Uses predicates from internal/task/predicates as the single source of truth.
|
||||||
// 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
|
|
||||||
func GetIOSCategoryForTask(task *models.Task) string {
|
func GetIOSCategoryForTask(task *models.Task) string {
|
||||||
if task.IsCancelled {
|
if predicates.IsCancelled(task) {
|
||||||
return IOSCategoryTaskCancelled
|
return IOSCategoryTaskCancelled
|
||||||
}
|
}
|
||||||
if isTaskCompleted(task) {
|
if predicates.IsCompleted(task) {
|
||||||
return IOSCategoryTaskCompleted
|
return IOSCategoryTaskCompleted
|
||||||
}
|
}
|
||||||
if task.Status != nil && task.Status.Name == "In Progress" {
|
if predicates.IsInProgress(task) {
|
||||||
return IOSCategoryTaskInProgress
|
return IOSCategoryTaskInProgress
|
||||||
}
|
}
|
||||||
return IOSCategoryTaskActionable
|
return IOSCategoryTaskActionable
|
||||||
|
|||||||
@@ -4,12 +4,16 @@
|
|||||||
// The chain evaluates tasks in a specific priority order, with each handler
|
// 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
|
// 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.
|
// 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
|
package categorization
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/treytartt/casera-api/internal/models"
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
|
"github.com/treytartt/casera-api/internal/task/predicates"
|
||||||
)
|
)
|
||||||
|
|
||||||
// KanbanColumn represents the possible kanban column names
|
// KanbanColumn represents the possible kanban column names
|
||||||
@@ -37,12 +41,12 @@ type Context struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewContext creates a new categorization context with sensible defaults
|
// 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 {
|
if daysThreshold <= 0 {
|
||||||
daysThreshold = 30
|
daysThreshold = 30
|
||||||
}
|
}
|
||||||
return &Context{
|
return &Context{
|
||||||
Task: task,
|
Task: t,
|
||||||
Now: time.Now().UTC(),
|
Now: time.Now().UTC(),
|
||||||
DaysThreshold: daysThreshold,
|
DaysThreshold: daysThreshold,
|
||||||
}
|
}
|
||||||
@@ -83,6 +87,7 @@ func (h *BaseHandler) HandleNext(ctx *Context) KanbanColumn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === Concrete Handlers ===
|
// === Concrete Handlers ===
|
||||||
|
// Each handler uses predicates from the task package as the source of truth.
|
||||||
|
|
||||||
// CancelledHandler checks if the task is cancelled
|
// CancelledHandler checks if the task is cancelled
|
||||||
// Priority: 1 (highest - checked first)
|
// Priority: 1 (highest - checked first)
|
||||||
@@ -91,7 +96,8 @@ type CancelledHandler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *CancelledHandler) Handle(ctx *Context) KanbanColumn {
|
func (h *CancelledHandler) Handle(ctx *Context) KanbanColumn {
|
||||||
if ctx.Task.IsCancelled {
|
// Uses predicate: predicates.IsCancelled
|
||||||
|
if predicates.IsCancelled(ctx.Task) {
|
||||||
return ColumnCancelled
|
return ColumnCancelled
|
||||||
}
|
}
|
||||||
return h.HandleNext(ctx)
|
return h.HandleNext(ctx)
|
||||||
@@ -104,10 +110,9 @@ type CompletedHandler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *CompletedHandler) Handle(ctx *Context) KanbanColumn {
|
func (h *CompletedHandler) Handle(ctx *Context) KanbanColumn {
|
||||||
// A task is completed if:
|
// Uses predicate: predicates.IsCompleted
|
||||||
// - It has at least one completion record
|
// A task is completed if NextDueDate is nil AND has at least one completion
|
||||||
// - AND it has no NextDueDate (meaning it's a one-time task or the cycle is done)
|
if predicates.IsCompleted(ctx.Task) {
|
||||||
if ctx.Task.NextDueDate == nil && len(ctx.Task.Completions) > 0 {
|
|
||||||
return ColumnCompleted
|
return ColumnCompleted
|
||||||
}
|
}
|
||||||
return h.HandleNext(ctx)
|
return h.HandleNext(ctx)
|
||||||
@@ -120,7 +125,8 @@ type InProgressHandler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *InProgressHandler) Handle(ctx *Context) KanbanColumn {
|
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 ColumnInProgress
|
||||||
}
|
}
|
||||||
return h.HandleNext(ctx)
|
return h.HandleNext(ctx)
|
||||||
@@ -133,22 +139,16 @@ type OverdueHandler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *OverdueHandler) Handle(ctx *Context) KanbanColumn {
|
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) {
|
if effectiveDate != nil && effectiveDate.Before(ctx.Now) {
|
||||||
return ColumnOverdue
|
return ColumnOverdue
|
||||||
}
|
}
|
||||||
return h.HandleNext(ctx)
|
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
|
// DueSoonHandler checks if the task is due within the threshold period
|
||||||
// Priority: 5
|
// Priority: 5
|
||||||
type DueSoonHandler struct {
|
type DueSoonHandler struct {
|
||||||
@@ -156,7 +156,8 @@ type DueSoonHandler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DueSoonHandler) Handle(ctx *Context) KanbanColumn {
|
func (h *DueSoonHandler) Handle(ctx *Context) KanbanColumn {
|
||||||
effectiveDate := h.getEffectiveDate(ctx.Task)
|
// Uses predicate: predicates.EffectiveDate
|
||||||
|
effectiveDate := predicates.EffectiveDate(ctx.Task)
|
||||||
threshold := ctx.ThresholdDate()
|
threshold := ctx.ThresholdDate()
|
||||||
|
|
||||||
if effectiveDate != nil && effectiveDate.Before(threshold) {
|
if effectiveDate != nil && effectiveDate.Before(threshold) {
|
||||||
@@ -165,13 +166,6 @@ func (h *DueSoonHandler) Handle(ctx *Context) KanbanColumn {
|
|||||||
return h.HandleNext(ctx)
|
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
|
// UpcomingHandler is the final handler that catches all remaining tasks
|
||||||
// Priority: 6 (lowest - default)
|
// Priority: 6 (lowest - default)
|
||||||
type UpcomingHandler struct {
|
type UpcomingHandler struct {
|
||||||
@@ -179,7 +173,10 @@ type UpcomingHandler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *UpcomingHandler) Handle(ctx *Context) KanbanColumn {
|
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
|
return ColumnUpcoming
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,8 +208,8 @@ func NewChain() *Chain {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Categorize determines which kanban column a task belongs to
|
// Categorize determines which kanban column a task belongs to
|
||||||
func (c *Chain) Categorize(task *models.Task, daysThreshold int) KanbanColumn {
|
func (c *Chain) Categorize(t *models.Task, daysThreshold int) KanbanColumn {
|
||||||
ctx := NewContext(task, daysThreshold)
|
ctx := NewContext(t, daysThreshold)
|
||||||
return c.head.Handle(ctx)
|
return c.head.Handle(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,13 +224,13 @@ func (c *Chain) CategorizeWithContext(ctx *Context) KanbanColumn {
|
|||||||
var defaultChain = NewChain()
|
var defaultChain = NewChain()
|
||||||
|
|
||||||
// DetermineKanbanColumn is a convenience function that uses the default chain
|
// DetermineKanbanColumn is a convenience function that uses the default chain
|
||||||
func DetermineKanbanColumn(task *models.Task, daysThreshold int) string {
|
func DetermineKanbanColumn(t *models.Task, daysThreshold int) string {
|
||||||
return defaultChain.Categorize(task, daysThreshold).String()
|
return defaultChain.Categorize(t, daysThreshold).String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CategorizeTask is an alias for DetermineKanbanColumn with a more descriptive name
|
// CategorizeTask is an alias for DetermineKanbanColumn with a more descriptive name
|
||||||
func CategorizeTask(task *models.Task, daysThreshold int) KanbanColumn {
|
func CategorizeTask(t *models.Task, daysThreshold int) KanbanColumn {
|
||||||
return defaultChain.Categorize(task, daysThreshold)
|
return defaultChain.Categorize(t, daysThreshold)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CategorizeTasksIntoColumns categorizes multiple tasks into their respective columns
|
// CategorizeTasksIntoColumns categorizes multiple tasks into their respective columns
|
||||||
@@ -250,9 +247,9 @@ func CategorizeTasksIntoColumns(tasks []models.Task, daysThreshold int) map[Kanb
|
|||||||
|
|
||||||
// Categorize each task
|
// Categorize each task
|
||||||
chain := NewChain()
|
chain := NewChain()
|
||||||
for _, task := range tasks {
|
for _, t := range tasks {
|
||||||
column := chain.Categorize(&task, daysThreshold)
|
column := chain.Categorize(&t, daysThreshold)
|
||||||
result[column] = append(result[column], task)
|
result[column] = append(result[column], t)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
package categorization
|
package categorization_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
|
|
||||||
"github.com/treytartt/casera-api/internal/models"
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
|
"github.com/treytartt/casera-api/internal/task/categorization"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Helper to create a time pointer
|
// Helper to create a time pointer
|
||||||
@@ -14,362 +13,224 @@ func timePtr(t time.Time) *time.Time {
|
|||||||
return &t
|
return &t
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to create a uint pointer
|
func TestCategorizeTask_PriorityOrder(t *testing.T) {
|
||||||
func uintPtr(v uint) *uint {
|
now := time.Now().UTC()
|
||||||
return &v
|
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
|
tests := []struct {
|
||||||
func makeCompletion(id uint) models.TaskCompletion {
|
name string
|
||||||
c := models.TaskCompletion{CompletedAt: time.Now()}
|
task *models.Task
|
||||||
c.ID = id
|
expected categorization.KanbanColumn
|
||||||
return c
|
}{
|
||||||
}
|
// 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
|
// Priority 2: Completed
|
||||||
func makeTask(id uint) models.Task {
|
{
|
||||||
t := models.Task{}
|
name: "completed: NextDueDate nil with completions",
|
||||||
t.ID = id
|
task: &models.Task{
|
||||||
return t
|
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) {
|
// Priority 3: In Progress
|
||||||
chain := NewChain()
|
{
|
||||||
|
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) {
|
// Priority 4: Overdue
|
||||||
task := &models.Task{
|
{
|
||||||
IsCancelled: true,
|
name: "overdue: effective date in past",
|
||||||
}
|
task: &models.Task{
|
||||||
result := chain.Categorize(task, 30)
|
IsCancelled: false,
|
||||||
assert.Equal(t, ColumnCancelled, result)
|
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) {
|
// Priority 5: Due Soon
|
||||||
dueDate := time.Now().AddDate(0, 0, -10) // 10 days ago (overdue)
|
{
|
||||||
task := &models.Task{
|
name: "due soon: within threshold",
|
||||||
IsCancelled: true,
|
task: &models.Task{
|
||||||
DueDate: &dueDate,
|
IsCancelled: false,
|
||||||
}
|
IsArchived: false,
|
||||||
result := chain.Categorize(task, 30)
|
NextDueDate: timePtr(in5Days),
|
||||||
assert.Equal(t, ColumnCancelled, result)
|
Completions: []models.TaskCompletion{},
|
||||||
})
|
},
|
||||||
}
|
expected: categorization.ColumnDueSoon,
|
||||||
|
},
|
||||||
|
|
||||||
func TestCompletedHandler(t *testing.T) {
|
// Priority 6: Upcoming (default)
|
||||||
chain := NewChain()
|
{
|
||||||
|
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) {
|
for _, tt := range tests {
|
||||||
task := &models.Task{
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
NextDueDate: nil,
|
result := categorization.CategorizeTask(tt.task, daysThreshold)
|
||||||
Completions: []models.TaskCompletion{makeCompletion(1)},
|
if result != tt.expected {
|
||||||
}
|
t.Errorf("CategorizeTask() = %v, expected %v", result, tt.expected)
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCategorizeTasksIntoColumns(t *testing.T) {
|
func TestCategorizeTasksIntoColumns(t *testing.T) {
|
||||||
now := time.Now()
|
now := time.Now().UTC()
|
||||||
pastDate := now.AddDate(0, 0, -5)
|
yesterday := now.AddDate(0, 0, -1)
|
||||||
soonDate := now.AddDate(0, 0, 15)
|
in5Days := now.AddDate(0, 0, 5)
|
||||||
futureDate := now.AddDate(0, 0, 60)
|
in60Days := now.AddDate(0, 0, 60)
|
||||||
|
daysThreshold := 30
|
||||||
|
|
||||||
// Create tasks with proper IDs
|
tasks := []models.Task{
|
||||||
task1 := makeTask(1)
|
{BaseModel: models.BaseModel{ID: 1}, IsCancelled: true}, // Cancelled
|
||||||
task1.IsCancelled = true
|
{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)
|
result := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold)
|
||||||
task2.NextDueDate = nil
|
|
||||||
task2.Completions = []models.TaskCompletion{makeCompletion(1)}
|
|
||||||
|
|
||||||
task3 := makeTask(3)
|
// Check each column has the expected tasks
|
||||||
task3.Status = &models.TaskStatus{Name: "In Progress"}
|
if len(result[categorization.ColumnCancelled]) != 1 || result[categorization.ColumnCancelled][0].ID != 1 {
|
||||||
|
t.Errorf("Expected task 1 in Cancelled column")
|
||||||
task4 := makeTask(4)
|
}
|
||||||
task4.NextDueDate = &pastDate
|
if len(result[categorization.ColumnCompleted]) != 1 || result[categorization.ColumnCompleted][0].ID != 2 {
|
||||||
task4.Status = &models.TaskStatus{Name: "Pending"}
|
t.Errorf("Expected task 2 in Completed column")
|
||||||
|
}
|
||||||
task5 := makeTask(5)
|
if len(result[categorization.ColumnInProgress]) != 1 || result[categorization.ColumnInProgress][0].ID != 3 {
|
||||||
task5.NextDueDate = &soonDate
|
t.Errorf("Expected task 3 in InProgress column")
|
||||||
task5.Status = &models.TaskStatus{Name: "Pending"}
|
}
|
||||||
|
if len(result[categorization.ColumnOverdue]) != 1 || result[categorization.ColumnOverdue][0].ID != 4 {
|
||||||
task6 := makeTask(6)
|
t.Errorf("Expected task 4 in Overdue column")
|
||||||
task6.NextDueDate = &futureDate
|
}
|
||||||
task6.Status = &models.TaskStatus{Name: "Pending"}
|
if len(result[categorization.ColumnDueSoon]) != 1 || result[categorization.ColumnDueSoon][0].ID != 5 {
|
||||||
|
t.Errorf("Expected task 5 in DueSoon column")
|
||||||
tasks := []models.Task{task1, task2, task3, task4, task5, task6}
|
}
|
||||||
|
if len(result[categorization.ColumnUpcoming]) != 2 {
|
||||||
result := CategorizeTasksIntoColumns(tasks, 30)
|
t.Errorf("Expected 2 tasks in Upcoming column, got %d", len(result[categorization.ColumnUpcoming]))
|
||||||
|
}
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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{}
|
task := &models.Task{}
|
||||||
|
|
||||||
// Test that 0 or negative threshold defaults to 30
|
// Zero threshold should default to 30
|
||||||
ctx1 := NewContext(task, 0)
|
ctx := categorization.NewContext(task, 0)
|
||||||
assert.Equal(t, 30, ctx1.DaysThreshold)
|
if ctx.DaysThreshold != 30 {
|
||||||
|
t.Errorf("NewContext with 0 threshold should default to 30, got %d", ctx.DaysThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
ctx2 := NewContext(task, -5)
|
// Negative threshold should default to 30
|
||||||
assert.Equal(t, 30, ctx2.DaysThreshold)
|
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)
|
// Positive threshold should be used
|
||||||
assert.Equal(t, 14, ctx3.DaysThreshold)
|
ctx = categorization.NewContext(task, 45)
|
||||||
}
|
if ctx.DaysThreshold != 45 {
|
||||||
|
t.Errorf("NewContext with 45 threshold should be 45, got %d", ctx.DaysThreshold)
|
||||||
// Helper to create int pointer
|
}
|
||||||
func intPtr(v int) *int {
|
|
||||||
return &v
|
|
||||||
}
|
}
|
||||||
|
|||||||
668
internal/task/consistency_test.go
Normal file
668
internal/task/consistency_test.go
Normal 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
|
||||||
|
}
|
||||||
189
internal/task/predicates/predicates.go
Normal file
189
internal/task/predicates/predicates.go
Normal 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)
|
||||||
|
}
|
||||||
522
internal/task/predicates/predicates_test.go
Normal file
522
internal/task/predicates/predicates_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
274
internal/task/scopes/scopes.go
Normal file
274
internal/task/scopes/scopes.go
Normal 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")
|
||||||
|
}
|
||||||
706
internal/task/scopes/scopes_test.go
Normal file
706
internal/task/scopes/scopes_test.go
Normal 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
259
internal/task/task.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -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")
|
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
|
// Step 2: Query tasks due today or tomorrow only for eligible users
|
||||||
// A task is considered "completed" (and should be excluded) if:
|
// Completion detection logic matches internal/task/predicates.IsCompleted:
|
||||||
// - NextDueDate IS NULL AND it has at least one completion record
|
// A task is "completed" when NextDueDate == nil AND has at least one completion.
|
||||||
// This matches the kanban categorization logic
|
// See internal/task/scopes.ScopeNotCompleted for the SQL equivalent.
|
||||||
var dueSoonTasks []models.Task
|
var dueSoonTasks []models.Task
|
||||||
err = h.db.Preload("Status").Preload("Completions").Preload("Residence").
|
err = h.db.Preload("Status").Preload("Completions").Preload("Residence").
|
||||||
Where("(due_date >= ? AND due_date < ?) OR (next_due_date >= ? AND next_due_date < ?)",
|
Where("(due_date >= ? AND due_date < ?) OR (next_due_date >= ? AND next_due_date < ?)",
|
||||||
today, dayAfterTomorrow, today, dayAfterTomorrow).
|
today, dayAfterTomorrow, today, dayAfterTomorrow).
|
||||||
Where("is_cancelled = false").
|
Where("is_cancelled = false").
|
||||||
Where("is_archived = 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("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 ?))",
|
Where("(assigned_to_id IN ? OR residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ?))",
|
||||||
eligibleUserIDs, eligibleUserIDs).
|
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")
|
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
|
// Step 2: Query overdue tasks only for eligible users
|
||||||
// A task is considered "completed" (and should be excluded) if:
|
// Completion detection logic matches internal/task/predicates.IsCompleted:
|
||||||
// - NextDueDate IS NULL AND it has at least one completion record
|
// A task is "completed" when NextDueDate == nil AND has at least one completion.
|
||||||
// This matches the kanban categorization logic
|
// See internal/task/scopes.ScopeNotCompleted for the SQL equivalent.
|
||||||
var overdueTasks []models.Task
|
var overdueTasks []models.Task
|
||||||
err = h.db.Preload("Status").Preload("Completions").Preload("Residence").
|
err = h.db.Preload("Status").Preload("Completions").Preload("Residence").
|
||||||
Where("due_date < ? OR next_due_date < ?", today, today).
|
Where("due_date < ? OR next_due_date < ?", today, today).
|
||||||
Where("is_cancelled = false").
|
Where("is_cancelled = false").
|
||||||
Where("is_archived = 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("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 ?))",
|
Where("(assigned_to_id IN ? OR residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ?))",
|
||||||
eligibleUserIDs, eligibleUserIDs).
|
eligibleUserIDs, eligibleUserIDs).
|
||||||
@@ -299,6 +299,9 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error
|
|||||||
nextWeek := today.AddDate(0, 0, 7)
|
nextWeek := today.AddDate(0, 0, 7)
|
||||||
|
|
||||||
// Get all users with their task statistics
|
// 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 {
|
var userStats []struct {
|
||||||
UserID uint
|
UserID uint
|
||||||
TotalTasks int
|
TotalTasks int
|
||||||
@@ -310,8 +313,16 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error
|
|||||||
SELECT
|
SELECT
|
||||||
u.id as user_id,
|
u.id as user_id,
|
||||||
COUNT(DISTINCT t.id) as total_tasks,
|
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
|
||||||
COUNT(DISTINCT CASE WHEN t.due_date >= ? AND t.due_date < ? AND tc.id IS NULL THEN t.id END) as due_this_week
|
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
|
FROM auth_user u
|
||||||
JOIN residence_residence r ON r.owner_id = u.id OR r.id IN (
|
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
|
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
|
JOIN task_task t ON t.residence_id = r.id
|
||||||
AND t.is_cancelled = false
|
AND t.is_cancelled = false
|
||||||
AND t.is_archived = false
|
AND t.is_archived = false
|
||||||
LEFT JOIN task_taskcompletion tc ON t.id = tc.task_id
|
|
||||||
WHERE u.is_active = true
|
WHERE u.is_active = true
|
||||||
GROUP BY u.id
|
GROUP BY u.id
|
||||||
HAVING COUNT(DISTINCT t.id) > 0
|
HAVING COUNT(DISTINCT t.id) > 0
|
||||||
|
|||||||
Reference in New Issue
Block a user