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:
Trey t
2025-12-05 14:23:14 -06:00
parent bbf3999c79
commit 1b06c0639c
22 changed files with 2715 additions and 142 deletions

View 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
}