Add actionable push notifications and fix recurring task completion
Features: - Add task action buttons to push notifications (complete, view, cancel, etc.) - Add button types logic for different task states (overdue, in_progress, etc.) - Implement Chain of Responsibility pattern for task categorization - Add comprehensive kanban categorization documentation Fixes: - Reset recurring task status to Pending after completion so tasks appear in correct kanban column (was staying in "In Progress") - Fix PostgreSQL EXTRACT function error in overdue notifications query - Update seed data to properly set next_due_date for recurring tasks Admin: - Add tasks list to residence detail page - Fix task edit page to properly handle all fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
259
internal/task/categorization/chain.go
Normal file
259
internal/task/categorization/chain.go
Normal file
@@ -0,0 +1,259 @@
|
||||
// Package categorization implements the Chain of Responsibility pattern for
|
||||
// determining which kanban column a task belongs to.
|
||||
//
|
||||
// The chain evaluates tasks in a specific priority order, with each handler
|
||||
// checking if the task matches its criteria. If a handler matches, it returns
|
||||
// the column name; otherwise, it passes to the next handler in the chain.
|
||||
package categorization
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/models"
|
||||
)
|
||||
|
||||
// KanbanColumn represents the possible kanban column names
|
||||
type KanbanColumn string
|
||||
|
||||
const (
|
||||
ColumnOverdue KanbanColumn = "overdue_tasks"
|
||||
ColumnDueSoon KanbanColumn = "due_soon_tasks"
|
||||
ColumnUpcoming KanbanColumn = "upcoming_tasks"
|
||||
ColumnInProgress KanbanColumn = "in_progress_tasks"
|
||||
ColumnCompleted KanbanColumn = "completed_tasks"
|
||||
ColumnCancelled KanbanColumn = "cancelled_tasks"
|
||||
)
|
||||
|
||||
// String returns the string representation of the column
|
||||
func (c KanbanColumn) String() string {
|
||||
return string(c)
|
||||
}
|
||||
|
||||
// Context holds the data needed to categorize a task
|
||||
type Context struct {
|
||||
Task *models.Task
|
||||
Now time.Time
|
||||
DaysThreshold int
|
||||
}
|
||||
|
||||
// NewContext creates a new categorization context with sensible defaults
|
||||
func NewContext(task *models.Task, daysThreshold int) *Context {
|
||||
if daysThreshold <= 0 {
|
||||
daysThreshold = 30
|
||||
}
|
||||
return &Context{
|
||||
Task: task,
|
||||
Now: time.Now().UTC(),
|
||||
DaysThreshold: daysThreshold,
|
||||
}
|
||||
}
|
||||
|
||||
// ThresholdDate returns the date threshold for "due soon" categorization
|
||||
func (c *Context) ThresholdDate() time.Time {
|
||||
return c.Now.AddDate(0, 0, c.DaysThreshold)
|
||||
}
|
||||
|
||||
// Handler defines the interface for task categorization handlers
|
||||
type Handler interface {
|
||||
// SetNext sets the next handler in the chain
|
||||
SetNext(handler Handler) Handler
|
||||
|
||||
// Handle processes the task and returns the column name if matched,
|
||||
// or delegates to the next handler
|
||||
Handle(ctx *Context) KanbanColumn
|
||||
}
|
||||
|
||||
// BaseHandler provides default chaining behavior
|
||||
type BaseHandler struct {
|
||||
next Handler
|
||||
}
|
||||
|
||||
// SetNext sets the next handler and returns it for fluent chaining
|
||||
func (h *BaseHandler) SetNext(handler Handler) Handler {
|
||||
h.next = handler
|
||||
return handler
|
||||
}
|
||||
|
||||
// HandleNext delegates to the next handler or returns default
|
||||
func (h *BaseHandler) HandleNext(ctx *Context) KanbanColumn {
|
||||
if h.next != nil {
|
||||
return h.next.Handle(ctx)
|
||||
}
|
||||
return ColumnUpcoming // Default fallback
|
||||
}
|
||||
|
||||
// === Concrete Handlers ===
|
||||
|
||||
// CancelledHandler checks if the task is cancelled
|
||||
// Priority: 1 (highest - checked first)
|
||||
type CancelledHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
|
||||
func (h *CancelledHandler) Handle(ctx *Context) KanbanColumn {
|
||||
if ctx.Task.IsCancelled {
|
||||
return ColumnCancelled
|
||||
}
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
// CompletedHandler checks if the task is completed (one-time task with completions and no next due date)
|
||||
// Priority: 2
|
||||
type CompletedHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
|
||||
func (h *CompletedHandler) Handle(ctx *Context) KanbanColumn {
|
||||
// A task is completed if:
|
||||
// - It has at least one completion record
|
||||
// - AND it has no NextDueDate (meaning it's a one-time task or the cycle is done)
|
||||
if ctx.Task.NextDueDate == nil && len(ctx.Task.Completions) > 0 {
|
||||
return ColumnCompleted
|
||||
}
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
// InProgressHandler checks if the task status is "In Progress"
|
||||
// Priority: 3
|
||||
type InProgressHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
|
||||
func (h *InProgressHandler) Handle(ctx *Context) KanbanColumn {
|
||||
if ctx.Task.Status != nil && ctx.Task.Status.Name == "In Progress" {
|
||||
return ColumnInProgress
|
||||
}
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
// OverdueHandler checks if the task is overdue based on NextDueDate or DueDate
|
||||
// Priority: 4
|
||||
type OverdueHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
|
||||
func (h *OverdueHandler) Handle(ctx *Context) KanbanColumn {
|
||||
effectiveDate := h.getEffectiveDate(ctx.Task)
|
||||
if effectiveDate != nil && effectiveDate.Before(ctx.Now) {
|
||||
return ColumnOverdue
|
||||
}
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
func (h *OverdueHandler) getEffectiveDate(task *models.Task) *time.Time {
|
||||
// Prefer NextDueDate for recurring tasks
|
||||
if task.NextDueDate != nil {
|
||||
return task.NextDueDate
|
||||
}
|
||||
// Fall back to DueDate for initial categorization
|
||||
return task.DueDate
|
||||
}
|
||||
|
||||
// DueSoonHandler checks if the task is due within the threshold period
|
||||
// Priority: 5
|
||||
type DueSoonHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
|
||||
func (h *DueSoonHandler) Handle(ctx *Context) KanbanColumn {
|
||||
effectiveDate := h.getEffectiveDate(ctx.Task)
|
||||
threshold := ctx.ThresholdDate()
|
||||
|
||||
if effectiveDate != nil && effectiveDate.Before(threshold) {
|
||||
return ColumnDueSoon
|
||||
}
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
func (h *DueSoonHandler) getEffectiveDate(task *models.Task) *time.Time {
|
||||
if task.NextDueDate != nil {
|
||||
return task.NextDueDate
|
||||
}
|
||||
return task.DueDate
|
||||
}
|
||||
|
||||
// UpcomingHandler is the final handler that catches all remaining tasks
|
||||
// Priority: 6 (lowest - default)
|
||||
type UpcomingHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
|
||||
func (h *UpcomingHandler) Handle(ctx *Context) KanbanColumn {
|
||||
// This is the default catch-all
|
||||
return ColumnUpcoming
|
||||
}
|
||||
|
||||
// === Chain Builder ===
|
||||
|
||||
// Chain manages the categorization chain
|
||||
type Chain struct {
|
||||
head Handler
|
||||
}
|
||||
|
||||
// NewChain creates a new categorization chain with handlers in priority order
|
||||
func NewChain() *Chain {
|
||||
// Build the chain in priority order (first handler has highest priority)
|
||||
cancelled := &CancelledHandler{}
|
||||
completed := &CompletedHandler{}
|
||||
inProgress := &InProgressHandler{}
|
||||
overdue := &OverdueHandler{}
|
||||
dueSoon := &DueSoonHandler{}
|
||||
upcoming := &UpcomingHandler{}
|
||||
|
||||
// Chain them together: cancelled -> completed -> inProgress -> overdue -> dueSoon -> upcoming
|
||||
cancelled.SetNext(completed).
|
||||
SetNext(inProgress).
|
||||
SetNext(overdue).
|
||||
SetNext(dueSoon).
|
||||
SetNext(upcoming)
|
||||
|
||||
return &Chain{head: cancelled}
|
||||
}
|
||||
|
||||
// Categorize determines which kanban column a task belongs to
|
||||
func (c *Chain) Categorize(task *models.Task, daysThreshold int) KanbanColumn {
|
||||
ctx := NewContext(task, daysThreshold)
|
||||
return c.head.Handle(ctx)
|
||||
}
|
||||
|
||||
// CategorizeWithContext uses a pre-built context for categorization
|
||||
func (c *Chain) CategorizeWithContext(ctx *Context) KanbanColumn {
|
||||
return c.head.Handle(ctx)
|
||||
}
|
||||
|
||||
// === Convenience Functions ===
|
||||
|
||||
// defaultChain is a singleton chain instance for convenience
|
||||
var defaultChain = NewChain()
|
||||
|
||||
// DetermineKanbanColumn is a convenience function that uses the default chain
|
||||
func DetermineKanbanColumn(task *models.Task, daysThreshold int) string {
|
||||
return defaultChain.Categorize(task, daysThreshold).String()
|
||||
}
|
||||
|
||||
// CategorizeTask is an alias for DetermineKanbanColumn with a more descriptive name
|
||||
func CategorizeTask(task *models.Task, daysThreshold int) KanbanColumn {
|
||||
return defaultChain.Categorize(task, daysThreshold)
|
||||
}
|
||||
|
||||
// CategorizeTasksIntoColumns categorizes multiple tasks into their respective columns
|
||||
func CategorizeTasksIntoColumns(tasks []models.Task, daysThreshold int) map[KanbanColumn][]models.Task {
|
||||
result := make(map[KanbanColumn][]models.Task)
|
||||
|
||||
// Initialize all columns with empty slices
|
||||
for _, col := range []KanbanColumn{
|
||||
ColumnOverdue, ColumnDueSoon, ColumnUpcoming,
|
||||
ColumnInProgress, ColumnCompleted, ColumnCancelled,
|
||||
} {
|
||||
result[col] = make([]models.Task, 0)
|
||||
}
|
||||
|
||||
// Categorize each task
|
||||
chain := NewChain()
|
||||
for _, task := range tasks {
|
||||
column := chain.Categorize(&task, daysThreshold)
|
||||
result[column] = append(result[column], task)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
375
internal/task/categorization/chain_test.go
Normal file
375
internal/task/categorization/chain_test.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package categorization
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/models"
|
||||
)
|
||||
|
||||
// Helper to create a time pointer
|
||||
func timePtr(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
// Helper to create a uint pointer
|
||||
func uintPtr(v uint) *uint {
|
||||
return &v
|
||||
}
|
||||
|
||||
// Helper to create a completion with an ID
|
||||
func makeCompletion(id uint) models.TaskCompletion {
|
||||
c := models.TaskCompletion{CompletedAt: time.Now()}
|
||||
c.ID = id
|
||||
return c
|
||||
}
|
||||
|
||||
// Helper to create a task with an ID
|
||||
func makeTask(id uint) models.Task {
|
||||
t := models.Task{}
|
||||
t.ID = id
|
||||
return t
|
||||
}
|
||||
|
||||
func TestCancelledHandler(t *testing.T) {
|
||||
chain := NewChain()
|
||||
|
||||
t.Run("cancelled task goes to cancelled column", func(t *testing.T) {
|
||||
task := &models.Task{
|
||||
IsCancelled: true,
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnCancelled, result)
|
||||
})
|
||||
|
||||
t.Run("cancelled task with due date still goes to cancelled", func(t *testing.T) {
|
||||
dueDate := time.Now().AddDate(0, 0, -10) // 10 days ago (overdue)
|
||||
task := &models.Task{
|
||||
IsCancelled: true,
|
||||
DueDate: &dueDate,
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnCancelled, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompletedHandler(t *testing.T) {
|
||||
chain := NewChain()
|
||||
|
||||
t.Run("one-time task with completion and no next_due_date goes to completed", func(t *testing.T) {
|
||||
task := &models.Task{
|
||||
NextDueDate: nil,
|
||||
Completions: []models.TaskCompletion{makeCompletion(1)},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnCompleted, result)
|
||||
})
|
||||
|
||||
t.Run("recurring task with completion but has next_due_date does NOT go to completed", func(t *testing.T) {
|
||||
nextDue := time.Now().AddDate(0, 0, 30)
|
||||
task := &models.Task{
|
||||
NextDueDate: &nextDue,
|
||||
Completions: []models.TaskCompletion{makeCompletion(1)},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
// Should go to due_soon or upcoming, not completed
|
||||
assert.NotEqual(t, ColumnCompleted, result)
|
||||
})
|
||||
|
||||
t.Run("task with no completions does not go to completed", func(t *testing.T) {
|
||||
task := &models.Task{
|
||||
NextDueDate: nil,
|
||||
Completions: []models.TaskCompletion{},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.NotEqual(t, ColumnCompleted, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInProgressHandler(t *testing.T) {
|
||||
chain := NewChain()
|
||||
|
||||
t.Run("task with In Progress status goes to in_progress column", func(t *testing.T) {
|
||||
task := &models.Task{
|
||||
Status: &models.TaskStatus{Name: "In Progress"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnInProgress, result)
|
||||
})
|
||||
|
||||
t.Run("task with Pending status does not go to in_progress", func(t *testing.T) {
|
||||
task := &models.Task{
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.NotEqual(t, ColumnInProgress, result)
|
||||
})
|
||||
|
||||
t.Run("task with nil status does not go to in_progress", func(t *testing.T) {
|
||||
task := &models.Task{
|
||||
Status: nil,
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.NotEqual(t, ColumnInProgress, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOverdueHandler(t *testing.T) {
|
||||
chain := NewChain()
|
||||
|
||||
t.Run("task with past next_due_date goes to overdue", func(t *testing.T) {
|
||||
pastDate := time.Now().AddDate(0, 0, -5) // 5 days ago
|
||||
task := &models.Task{
|
||||
NextDueDate: &pastDate,
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnOverdue, result)
|
||||
})
|
||||
|
||||
t.Run("task with past due_date (no next_due_date) goes to overdue", func(t *testing.T) {
|
||||
pastDate := time.Now().AddDate(0, 0, -5) // 5 days ago
|
||||
task := &models.Task{
|
||||
DueDate: &pastDate,
|
||||
NextDueDate: nil,
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnOverdue, result)
|
||||
})
|
||||
|
||||
t.Run("next_due_date takes precedence over due_date", func(t *testing.T) {
|
||||
pastDueDate := time.Now().AddDate(0, 0, -10) // 10 days ago
|
||||
futureNextDue := time.Now().AddDate(0, 0, 60) // 60 days from now
|
||||
task := &models.Task{
|
||||
DueDate: &pastDueDate,
|
||||
NextDueDate: &futureNextDue,
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
// Should be upcoming (60 days > 30 day threshold), not overdue
|
||||
assert.Equal(t, ColumnUpcoming, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDueSoonHandler(t *testing.T) {
|
||||
chain := NewChain()
|
||||
|
||||
t.Run("task due within threshold goes to due_soon", func(t *testing.T) {
|
||||
dueDate := time.Now().AddDate(0, 0, 15) // 15 days from now
|
||||
task := &models.Task{
|
||||
NextDueDate: &dueDate,
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
result := chain.Categorize(task, 30) // 30 day threshold
|
||||
assert.Equal(t, ColumnDueSoon, result)
|
||||
})
|
||||
|
||||
t.Run("task due exactly at threshold goes to due_soon", func(t *testing.T) {
|
||||
dueDate := time.Now().AddDate(0, 0, 29) // Just under 30 days
|
||||
task := &models.Task{
|
||||
NextDueDate: &dueDate,
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnDueSoon, result)
|
||||
})
|
||||
|
||||
t.Run("custom threshold is respected", func(t *testing.T) {
|
||||
dueDate := time.Now().AddDate(0, 0, 10) // 10 days from now
|
||||
task := &models.Task{
|
||||
NextDueDate: &dueDate,
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
// With 7 day threshold, 10 days out should be upcoming, not due_soon
|
||||
result := chain.Categorize(task, 7)
|
||||
assert.Equal(t, ColumnUpcoming, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpcomingHandler(t *testing.T) {
|
||||
chain := NewChain()
|
||||
|
||||
t.Run("task with future next_due_date beyond threshold goes to upcoming", func(t *testing.T) {
|
||||
futureDate := time.Now().AddDate(0, 0, 60) // 60 days from now
|
||||
task := &models.Task{
|
||||
NextDueDate: &futureDate,
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnUpcoming, result)
|
||||
})
|
||||
|
||||
t.Run("task with no due date goes to upcoming (default)", func(t *testing.T) {
|
||||
task := &models.Task{
|
||||
DueDate: nil,
|
||||
NextDueDate: nil,
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnUpcoming, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestChainPriorityOrder(t *testing.T) {
|
||||
chain := NewChain()
|
||||
|
||||
t.Run("cancelled takes priority over everything", func(t *testing.T) {
|
||||
pastDate := time.Now().AddDate(0, 0, -10)
|
||||
task := &models.Task{
|
||||
IsCancelled: true,
|
||||
DueDate: &pastDate,
|
||||
NextDueDate: nil,
|
||||
Completions: []models.TaskCompletion{makeCompletion(1)},
|
||||
Status: &models.TaskStatus{Name: "In Progress"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnCancelled, result)
|
||||
})
|
||||
|
||||
t.Run("completed takes priority over in_progress", func(t *testing.T) {
|
||||
task := &models.Task{
|
||||
IsCancelled: false,
|
||||
NextDueDate: nil,
|
||||
Completions: []models.TaskCompletion{makeCompletion(1)},
|
||||
Status: &models.TaskStatus{Name: "In Progress"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnCompleted, result)
|
||||
})
|
||||
|
||||
t.Run("in_progress takes priority over overdue", func(t *testing.T) {
|
||||
pastDate := time.Now().AddDate(0, 0, -10)
|
||||
task := &models.Task{
|
||||
IsCancelled: false,
|
||||
NextDueDate: &pastDate,
|
||||
Status: &models.TaskStatus{Name: "In Progress"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnInProgress, result)
|
||||
})
|
||||
|
||||
t.Run("overdue takes priority over due_soon", func(t *testing.T) {
|
||||
pastDate := time.Now().AddDate(0, 0, -1)
|
||||
task := &models.Task{
|
||||
IsCancelled: false,
|
||||
NextDueDate: &pastDate,
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnOverdue, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecurringTaskScenarios(t *testing.T) {
|
||||
chain := NewChain()
|
||||
|
||||
t.Run("annual task just completed should go to upcoming (next_due_date is 1 year out)", func(t *testing.T) {
|
||||
nextYear := time.Now().AddDate(1, 0, 0)
|
||||
task := &models.Task{
|
||||
NextDueDate: &nextYear,
|
||||
Completions: []models.TaskCompletion{makeCompletion(1)},
|
||||
Status: &models.TaskStatus{Name: "Pending"}, // Reset after completion
|
||||
Frequency: &models.TaskFrequency{Name: "Annually", Days: intPtr(365)},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnUpcoming, result)
|
||||
})
|
||||
|
||||
t.Run("monthly task due in 2 weeks should go to due_soon", func(t *testing.T) {
|
||||
twoWeeks := time.Now().AddDate(0, 0, 14)
|
||||
task := &models.Task{
|
||||
NextDueDate: &twoWeeks,
|
||||
Completions: []models.TaskCompletion{makeCompletion(1)},
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
Frequency: &models.TaskFrequency{Name: "Monthly", Days: intPtr(30)},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnDueSoon, result)
|
||||
})
|
||||
|
||||
t.Run("weekly task that is overdue should go to overdue", func(t *testing.T) {
|
||||
yesterday := time.Now().AddDate(0, 0, -1)
|
||||
task := &models.Task{
|
||||
NextDueDate: &yesterday,
|
||||
Completions: []models.TaskCompletion{makeCompletion(1)},
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
Frequency: &models.TaskFrequency{Name: "Weekly", Days: intPtr(7)},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnOverdue, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCategorizeTasksIntoColumns(t *testing.T) {
|
||||
now := time.Now()
|
||||
pastDate := now.AddDate(0, 0, -5)
|
||||
soonDate := now.AddDate(0, 0, 15)
|
||||
futureDate := now.AddDate(0, 0, 60)
|
||||
|
||||
// Create tasks with proper IDs
|
||||
task1 := makeTask(1)
|
||||
task1.IsCancelled = true
|
||||
|
||||
task2 := makeTask(2)
|
||||
task2.NextDueDate = nil
|
||||
task2.Completions = []models.TaskCompletion{makeCompletion(1)}
|
||||
|
||||
task3 := makeTask(3)
|
||||
task3.Status = &models.TaskStatus{Name: "In Progress"}
|
||||
|
||||
task4 := makeTask(4)
|
||||
task4.NextDueDate = &pastDate
|
||||
task4.Status = &models.TaskStatus{Name: "Pending"}
|
||||
|
||||
task5 := makeTask(5)
|
||||
task5.NextDueDate = &soonDate
|
||||
task5.Status = &models.TaskStatus{Name: "Pending"}
|
||||
|
||||
task6 := makeTask(6)
|
||||
task6.NextDueDate = &futureDate
|
||||
task6.Status = &models.TaskStatus{Name: "Pending"}
|
||||
|
||||
tasks := []models.Task{task1, task2, task3, task4, task5, task6}
|
||||
|
||||
result := CategorizeTasksIntoColumns(tasks, 30)
|
||||
|
||||
assert.Len(t, result[ColumnCancelled], 1)
|
||||
assert.Equal(t, uint(1), result[ColumnCancelled][0].ID)
|
||||
|
||||
assert.Len(t, result[ColumnCompleted], 1)
|
||||
assert.Equal(t, uint(2), result[ColumnCompleted][0].ID)
|
||||
|
||||
assert.Len(t, result[ColumnInProgress], 1)
|
||||
assert.Equal(t, uint(3), result[ColumnInProgress][0].ID)
|
||||
|
||||
assert.Len(t, result[ColumnOverdue], 1)
|
||||
assert.Equal(t, uint(4), result[ColumnOverdue][0].ID)
|
||||
|
||||
assert.Len(t, result[ColumnDueSoon], 1)
|
||||
assert.Equal(t, uint(5), result[ColumnDueSoon][0].ID)
|
||||
|
||||
assert.Len(t, result[ColumnUpcoming], 1)
|
||||
assert.Equal(t, uint(6), result[ColumnUpcoming][0].ID)
|
||||
}
|
||||
|
||||
func TestDefaultThreshold(t *testing.T) {
|
||||
task := &models.Task{}
|
||||
|
||||
// Test that 0 or negative threshold defaults to 30
|
||||
ctx1 := NewContext(task, 0)
|
||||
assert.Equal(t, 30, ctx1.DaysThreshold)
|
||||
|
||||
ctx2 := NewContext(task, -5)
|
||||
assert.Equal(t, 30, ctx2.DaysThreshold)
|
||||
|
||||
ctx3 := NewContext(task, 14)
|
||||
assert.Equal(t, 14, ctx3.DaysThreshold)
|
||||
}
|
||||
|
||||
// Helper to create int pointer
|
||||
func intPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
Reference in New Issue
Block a user