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

@@ -95,6 +95,7 @@ type TaskResponse struct {
FrequencyID *uint `json:"frequency_id"`
Frequency *TaskFrequencyResponse `json:"frequency,omitempty"`
DueDate *time.Time `json:"due_date"`
NextDueDate *time.Time `json:"next_due_date"` // For recurring tasks, updated after each completion
EstimatedCost *decimal.Decimal `json:"estimated_cost"`
ActualCost *decimal.Decimal `json:"actual_cost"`
ContractorID *uint `json:"contractor_id"`
@@ -229,7 +230,13 @@ func NewTaskCompletionResponse(c *models.TaskCompletion) TaskCompletionResponse
}
// NewTaskResponse creates a TaskResponse from a Task model
// Always includes kanban_column using default 30-day threshold
func NewTaskResponse(t *models.Task) TaskResponse {
return NewTaskResponseWithThreshold(t, 30)
}
// NewTaskResponseWithThreshold creates a TaskResponse with a custom days threshold for kanban column
func NewTaskResponseWithThreshold(t *models.Task, daysThreshold int) TaskResponse {
resp := TaskResponse{
ID: t.ID,
ResidenceID: t.ResidenceID,
@@ -242,6 +249,7 @@ func NewTaskResponse(t *models.Task) TaskResponse {
FrequencyID: t.FrequencyID,
AssignedToID: t.AssignedToID,
DueDate: t.DueDate,
NextDueDate: t.NextDueDate,
EstimatedCost: t.EstimatedCost,
ActualCost: t.ActualCost,
ContractorID: t.ContractorID,
@@ -249,6 +257,7 @@ func NewTaskResponse(t *models.Task) TaskResponse {
IsArchived: t.IsArchived,
ParentTaskID: t.ParentTaskID,
CompletionCount: len(t.Completions),
KanbanColumn: DetermineKanbanColumn(t, daysThreshold),
CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt,
}
@@ -348,17 +357,23 @@ func NewTaskCompletionWithTaskResponse(c *models.TaskCompletion, task *models.Ta
resp := NewTaskCompletionResponse(c)
if task != nil {
taskResp := NewTaskResponse(task)
taskResp.KanbanColumn = DetermineKanbanColumn(task, daysThreshold)
taskResp := NewTaskResponseWithThreshold(task, daysThreshold)
resp.Task = &taskResp
}
return resp
}
// DetermineKanbanColumn determines which kanban column a task belongs to
// Uses the same logic as task_repo.go GetKanbanData
// DetermineKanbanColumn determines which kanban column a task belongs to.
// This is a wrapper around the Chain of Responsibility implementation in
// internal/task/categorization package. See that package for detailed
// documentation on the categorization logic.
//
// Deprecated: Use categorization.DetermineKanbanColumn directly for new code.
func DetermineKanbanColumn(task *models.Task, daysThreshold int) string {
// Import would cause circular dependency, so we replicate the logic here
// for backwards compatibility. The authoritative implementation is in
// internal/task/categorization/chain.go
if daysThreshold <= 0 {
daysThreshold = 30 // Default
}
@@ -366,31 +381,37 @@ func DetermineKanbanColumn(task *models.Task, daysThreshold int) string {
now := time.Now().UTC()
threshold := now.AddDate(0, 0, daysThreshold)
// Priority order (same as GetKanbanData):
// 1. Cancelled
// Priority order (Chain of Responsibility):
// 1. Cancelled (highest priority)
if task.IsCancelled {
return "cancelled_tasks"
}
// 2. Completed (has completions)
if len(task.Completions) > 0 {
// 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
// 3. In Progress (status check)
if task.Status != nil && task.Status.Name == "In Progress" {
return "in_progress_tasks"
}
// 4. Due date based
if task.DueDate != nil {
if task.DueDate.Before(now) {
// 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"
} else if task.DueDate.Before(threshold) {
}
// 5. Due Soon (within threshold)
if effectiveDate.Before(threshold) {
return "due_soon_tasks"
}
}
// Default: upcoming
// 6. Upcoming (default/fallback)
return "upcoming_tasks"
}