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

@@ -3,10 +3,10 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, Trash2, Pencil } from 'lucide-react'; import { ArrowLeft, Trash2, Pencil, ExternalLink } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { residencesApi } from '@/lib/api'; import { residencesApi, tasksApi } from '@/lib/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
@@ -17,6 +17,14 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
export function ResidenceDetailClient() { export function ResidenceDetailClient() {
const params = useParams(); const params = useParams();
@@ -30,6 +38,12 @@ export function ResidenceDetailClient() {
enabled: !!residenceId, enabled: !!residenceId,
}); });
const { data: tasksData, isLoading: isLoadingTasks } = useQuery({
queryKey: ['residence-tasks', residenceId],
queryFn: () => tasksApi.list({ residence_id: residenceId, per_page: 100 }),
enabled: !!residenceId,
});
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: () => residencesApi.delete(residenceId), mutationFn: () => residencesApi.delete(residenceId),
onSuccess: () => { onSuccess: () => {
@@ -217,6 +231,70 @@ export function ResidenceDetailClient() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Tasks */}
<Card>
<CardHeader>
<CardTitle>Tasks</CardTitle>
<CardDescription>All tasks for this property</CardDescription>
</CardHeader>
<CardContent>
{isLoadingTasks ? (
<div className="text-muted-foreground">Loading tasks...</div>
) : !tasksData?.data || tasksData.data.length === 0 ? (
<div className="text-muted-foreground">No tasks for this property</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>Priority</TableHead>
<TableHead>Category</TableHead>
<TableHead>Due Date</TableHead>
<TableHead>Created By</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tasksData.data.map((task) => (
<TableRow key={task.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{task.title}
{task.is_cancelled && (
<Badge variant="destructive" className="text-xs">Cancelled</Badge>
)}
{task.is_archived && (
<Badge variant="secondary" className="text-xs">Archived</Badge>
)}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{task.status_name || '-'}</Badge>
</TableCell>
<TableCell>{task.priority_name || '-'}</TableCell>
<TableCell>{task.category_name || '-'}</TableCell>
<TableCell>
{task.due_date
? new Date(task.due_date).toLocaleDateString()
: '-'}
</TableCell>
<TableCell>{task.created_by_name}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" asChild>
<Link href={`/tasks/${task.id}`}>
<ExternalLink className="h-4 w-4" />
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div> </div>
); );
} }

View File

@@ -84,8 +84,11 @@ export default function EditTaskPage() {
const [formInitialized, setFormInitialized] = useState(false); const [formInitialized, setFormInitialized] = useState(false);
// Wait for ALL data including lookups before initializing form
const lookupsLoaded = !categoriesLoading && !prioritiesLoading && !statusesLoading && !frequenciesLoading;
useEffect(() => { useEffect(() => {
if (task && !formInitialized) { if (task && lookupsLoaded && !formInitialized) {
setFormData({ setFormData({
residence_id: task.residence_id, residence_id: task.residence_id,
created_by_id: task.created_by_id, created_by_id: task.created_by_id,
@@ -106,9 +109,9 @@ export default function EditTaskPage() {
}); });
setFormInitialized(true); setFormInitialized(true);
} }
}, [task, formInitialized]); }, [task, lookupsLoaded, formInitialized]);
const isDataLoading = taskLoading || usersLoading || residencesLoading || categoriesLoading || prioritiesLoading || statusesLoading || frequenciesLoading || !formInitialized; const isDataLoading = taskLoading || usersLoading || residencesLoading || !lookupsLoaded || !formInitialized;
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: (data: UpdateTaskRequest) => tasksApi.update(taskId, data), mutationFn: (data: UpdateTaskRequest) => tasksApi.update(taskId, data),
@@ -322,8 +325,14 @@ export default function EditTaskPage() {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="status_id">Status</Label> <Label htmlFor="status_id">Status</Label>
<Select <Select
value={formData.status_id?.toString() || 'none'} value={formData.status_id !== undefined ? formData.status_id.toString() : 'none'}
onValueChange={(value) => updateField('status_id', value === 'none' ? undefined : Number(value))} onValueChange={(value) => {
const newValue = value === 'none' ? undefined : Number(value);
// Only update if actually different (prevents spurious triggers)
if (newValue !== formData.status_id) {
updateField('status_id', newValue);
}
}}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select status" /> <SelectValue placeholder="Select status" />

View File

@@ -0,0 +1,308 @@
# Task Kanban Categorization
This document explains how tasks are categorized into kanban columns in the Casera application.
## Overview
The task categorization system uses the **Chain of Responsibility** design pattern to determine which kanban column a task belongs to. Each handler in the chain evaluates the task against specific criteria, and if matched, returns the appropriate column. If not matched, the task is passed to the next handler.
## Architecture
### Design Pattern: Chain of Responsibility
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Cancelled │───▶│ Completed │───▶│ In Progress │───▶│ Overdue │───▶│ Due Soon │───▶│ Upcoming │
│ Handler │ │ Handler │ │ Handler │ │ Handler │ │ Handler │ │ Handler │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
"cancelled_tasks" "completed_tasks" "in_progress_tasks" "overdue_tasks" "due_soon_tasks" "upcoming_tasks"
```
### Source Files
- **Chain Implementation**: `internal/task/categorization/chain.go`
- **Tests**: `internal/task/categorization/chain_test.go`
- **Legacy Wrapper**: `internal/dto/responses/task.go` (`DetermineKanbanColumn`)
- **Repository Usage**: `internal/repositories/task_repo.go` (`GetKanbanData`)
## Kanban Columns
The system supports 6 kanban columns, evaluated in strict priority order:
| Priority | Column Name | Display Name | Description |
|----------|-------------|--------------|-------------|
| 1 | `cancelled_tasks` | Cancelled | Tasks that have been cancelled |
| 2 | `completed_tasks` | Completed | One-time tasks that are done |
| 3 | `in_progress_tasks` | In Progress | Tasks currently being worked on |
| 4 | `overdue_tasks` | Overdue | Tasks past their due date |
| 5 | `due_soon_tasks` | Due Soon | Tasks due within the threshold (default: 30 days) |
| 6 | `upcoming_tasks` | Upcoming | All other active tasks |
## Categorization Logic (Step by Step)
### Step 1: Cancelled Check (Highest Priority)
**Handler**: `CancelledHandler`
**Condition**: `task.IsCancelled == true`
**Result**: `cancelled_tasks`
A cancelled task always goes to the cancelled column, regardless of any other attributes. This is the first check because cancellation represents a terminal state that overrides all other considerations.
```go
if task.IsCancelled {
return "cancelled_tasks"
}
```
### Step 2: Completed Check
**Handler**: `CompletedHandler`
**Condition**: `task.NextDueDate == nil && len(task.Completions) > 0`
**Result**: `completed_tasks`
A task is considered "completed" when:
1. It has at least one completion record (someone marked it done)
2. AND it has no `NextDueDate` (meaning it's a one-time task)
**Important**: Recurring tasks with completions but with a `NextDueDate` set are NOT considered completed - they continue in their active cycle.
```go
if task.NextDueDate == nil && len(task.Completions) > 0 {
return "completed_tasks"
}
```
### Step 3: In Progress Check
**Handler**: `InProgressHandler`
**Condition**: `task.Status != nil && task.Status.Name == "In Progress"`
**Result**: `in_progress_tasks`
Tasks with the "In Progress" status are grouped together regardless of their due date. This allows users to see what's actively being worked on.
```go
if task.Status != nil && task.Status.Name == "In Progress" {
return "in_progress_tasks"
}
```
### Step 4: Overdue Check
**Handler**: `OverdueHandler`
**Condition**: `effectiveDate.Before(now)`
**Result**: `overdue_tasks`
The "effective date" is determined as:
1. `NextDueDate` (preferred - used for recurring tasks after completion)
2. `DueDate` (fallback - used for initial scheduling)
If the effective date is in the past, the task is overdue.
```go
effectiveDate := task.NextDueDate
if effectiveDate == nil {
effectiveDate = task.DueDate
}
if effectiveDate != nil && effectiveDate.Before(now) {
return "overdue_tasks"
}
```
### Step 5: Due Soon Check
**Handler**: `DueSoonHandler`
**Condition**: `effectiveDate.Before(threshold)`
**Result**: `due_soon_tasks`
Where `threshold = now + daysThreshold` (default 30 days)
Tasks due within the threshold period are considered "due soon" and need attention.
```go
threshold := now.AddDate(0, 0, daysThreshold)
if effectiveDate != nil && effectiveDate.Before(threshold) {
return "due_soon_tasks"
}
```
### Step 6: Upcoming (Default)
**Handler**: `UpcomingHandler`
**Condition**: None (catches all remaining tasks)
**Result**: `upcoming_tasks`
Any task that doesn't match the above criteria falls into "upcoming". This includes:
- Tasks with due dates beyond the threshold
- Tasks with no due date set
- Future scheduled tasks
```go
return "upcoming_tasks"
```
## Key Concepts
### DueDate vs NextDueDate
| Field | Purpose | When Set |
|-------|---------|----------|
| `DueDate` | Original/initial due date | When task is created |
| `NextDueDate` | Next occurrence for recurring tasks | After each completion |
**For recurring tasks**: `NextDueDate` takes precedence over `DueDate` for categorization. After completing a recurring task:
1. A new completion record is created
2. `NextDueDate` is calculated as `completionDate + frequencyDays`
3. Status is reset to "Pending"
4. Task moves to the appropriate column based on the new `NextDueDate`
### One-Time vs Recurring Tasks
**One-Time Tasks** (frequency = "Once" or no frequency):
- When completed: `NextDueDate` is set to `nil`
- Categorization: Goes to `completed_tasks` column
- Status: Remains as-is (usually "Completed" or last status)
**Recurring Tasks** (weekly, monthly, annually, etc.):
- When completed: `NextDueDate` is set to `completionDate + frequencyDays`
- Categorization: Based on new `NextDueDate` (could be upcoming, due_soon, etc.)
- Status: **Reset to "Pending"** to allow proper column placement
### The "In Progress" Gotcha
**Important**: Tasks with "In Progress" status will stay in the `in_progress_tasks` column even if they're overdue!
This is by design - "In Progress" indicates active work, so the task should be visible there. However, this means:
1. If a recurring task is marked "In Progress" and then completed
2. The status MUST be reset to "Pending" after completion
3. Otherwise, the task stays in "In Progress" instead of moving to "Upcoming"
This is handled automatically in `TaskService.CreateCompletion()`:
```go
if isRecurringTask {
pendingStatusID := uint(1)
task.StatusID = &pendingStatusID
}
```
## Configuration
### Days Threshold
The `daysThreshold` parameter controls what counts as "due soon":
- Default: 30 days
- Configurable per-request via query parameter
```go
// Example: Consider tasks due within 7 days as "due soon"
chain.Categorize(task, 7)
```
## Column Metadata
Each column has associated metadata for UI rendering:
```go
{
Name: "overdue_tasks",
DisplayName: "Overdue",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"},
Color: "#FF3B30",
}
```
### Button Types by Column
| Column | Available Actions |
|--------|-------------------|
| `overdue_tasks` | edit, complete, cancel, mark_in_progress |
| `in_progress_tasks` | edit, complete, cancel |
| `due_soon_tasks` | edit, complete, cancel, mark_in_progress |
| `upcoming_tasks` | edit, complete, cancel, mark_in_progress |
| `completed_tasks` | (none - read-only) |
| `cancelled_tasks` | uncancel, delete |
## Usage Examples
### Basic Categorization
```go
import "github.com/treytartt/casera-api/internal/task/categorization"
task := &models.Task{
DueDate: time.Now().AddDate(0, 0, 15), // 15 days from now
Status: &models.TaskStatus{Name: "Pending"},
}
column := categorization.DetermineKanbanColumn(task, 30)
// Returns: "due_soon_tasks"
```
### Categorize Multiple Tasks
```go
tasks := []models.Task{...}
columns := categorization.CategorizeTasksIntoColumns(tasks, 30)
// Returns map[KanbanColumn][]models.Task with tasks organized by column
```
### Custom Chain (Advanced)
```go
chain := categorization.NewChain()
ctx := categorization.NewContext(task, 14) // 14-day threshold
column := chain.CategorizeWithContext(ctx)
```
## Testing
The categorization logic is thoroughly tested in `chain_test.go`:
```bash
go test ./internal/task/categorization/... -v
```
Key test scenarios:
- Each handler's matching criteria
- Priority order between handlers
- Recurring task lifecycle
- Edge cases (nil dates, empty completions, etc.)
## Troubleshooting
### Task stuck in wrong column?
1. **Check `IsCancelled`**: Cancelled takes highest priority
2. **Check `NextDueDate`**: For recurring tasks, this determines placement
3. **Check `Status`**: "In Progress" overrides date-based categorization
4. **Check `Completions`**: Empty + nil NextDueDate = upcoming, not completed
### Recurring task not moving to Upcoming after completion?
Verify that `CreateCompletion` is:
1. Setting `NextDueDate` correctly
2. Resetting `StatusID` to Pending (1)
### Task showing overdue but due date looks correct?
The system uses UTC times. Ensure dates are compared in UTC:
```go
now := time.Now().UTC()
```

View File

@@ -169,7 +169,6 @@ func (h *AdminTaskHandler) Update(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Residence not found"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Residence not found"})
return return
} }
task.ResidenceID = *req.ResidenceID
} }
// Verify created_by if changing // Verify created_by if changing
if req.CreatedByID != nil { if req.CreatedByID != nil {
@@ -178,7 +177,6 @@ func (h *AdminTaskHandler) Update(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Created by user not found"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Created by user not found"})
return return
} }
task.CreatedByID = *req.CreatedByID
} }
// Verify assigned_to if changing // Verify assigned_to if changing
if req.AssignedToID != nil { if req.AssignedToID != nil {
@@ -187,57 +185,68 @@ func (h *AdminTaskHandler) Update(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Assigned to user not found"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Assigned to user not found"})
return return
} }
task.AssignedToID = req.AssignedToID }
// Build update map with only the fields that were provided
updates := make(map[string]interface{})
if req.ResidenceID != nil {
updates["residence_id"] = *req.ResidenceID
}
if req.CreatedByID != nil {
updates["created_by_id"] = *req.CreatedByID
}
if req.AssignedToID != nil {
updates["assigned_to_id"] = *req.AssignedToID
} }
if req.Title != nil { if req.Title != nil {
task.Title = *req.Title updates["title"] = *req.Title
} }
if req.Description != nil { if req.Description != nil {
task.Description = *req.Description updates["description"] = *req.Description
} }
if req.CategoryID != nil { if req.CategoryID != nil {
task.CategoryID = req.CategoryID updates["category_id"] = *req.CategoryID
} }
if req.PriorityID != nil { if req.PriorityID != nil {
task.PriorityID = req.PriorityID updates["priority_id"] = *req.PriorityID
} }
if req.StatusID != nil { if req.StatusID != nil {
task.StatusID = req.StatusID updates["status_id"] = *req.StatusID
} }
if req.FrequencyID != nil { if req.FrequencyID != nil {
task.FrequencyID = req.FrequencyID updates["frequency_id"] = *req.FrequencyID
} }
if req.DueDate != nil { if req.DueDate != nil {
if dueDate, err := time.Parse("2006-01-02", *req.DueDate); err == nil { if dueDate, err := time.Parse("2006-01-02", *req.DueDate); err == nil {
task.DueDate = &dueDate updates["due_date"] = dueDate
} }
} }
if req.EstimatedCost != nil { if req.EstimatedCost != nil {
d := decimal.NewFromFloat(*req.EstimatedCost) updates["estimated_cost"] = decimal.NewFromFloat(*req.EstimatedCost)
task.EstimatedCost = &d
} }
if req.ActualCost != nil { if req.ActualCost != nil {
d := decimal.NewFromFloat(*req.ActualCost) updates["actual_cost"] = decimal.NewFromFloat(*req.ActualCost)
task.ActualCost = &d
} }
if req.ContractorID != nil { if req.ContractorID != nil {
task.ContractorID = req.ContractorID updates["contractor_id"] = *req.ContractorID
} }
if req.ParentTaskID != nil { if req.ParentTaskID != nil {
task.ParentTaskID = req.ParentTaskID updates["parent_task_id"] = *req.ParentTaskID
} }
if req.IsCancelled != nil { if req.IsCancelled != nil {
task.IsCancelled = *req.IsCancelled updates["is_cancelled"] = *req.IsCancelled
} }
if req.IsArchived != nil { if req.IsArchived != nil {
task.IsArchived = *req.IsArchived updates["is_archived"] = *req.IsArchived
} }
if err := h.db.Save(&task).Error; err != nil { // Use Updates with map to only update specified fields
if err := h.db.Model(&task).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update task"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update task"})
return return
} }
// Reload with preloads for response
h.db.Preload("Residence").Preload("CreatedBy").Preload("Category").Preload("Priority").Preload("Status").First(&task, id) h.db.Preload("Residence").Preload("CreatedBy").Preload("Category").Preload("Priority").Preload("Status").First(&task, id)
c.JSON(http.StatusOK, h.toTaskResponse(&task)) c.JSON(http.StatusOK, h.toTaskResponse(&task))
} }
@@ -320,7 +329,7 @@ func (h *AdminTaskHandler) Delete(c *gin.Context) {
// Soft delete - archive and cancel // Soft delete - archive and cancel
task.IsArchived = true task.IsArchived = true
task.IsCancelled = true task.IsCancelled = true
if err := h.db.Save(&task).Error; err != nil { if err := h.db.Omit("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Status", "Frequency", "ParentTask", "Completions").Save(&task).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete task"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete task"})
return return
} }

View File

@@ -95,6 +95,7 @@ type TaskResponse struct {
FrequencyID *uint `json:"frequency_id"` FrequencyID *uint `json:"frequency_id"`
Frequency *TaskFrequencyResponse `json:"frequency,omitempty"` Frequency *TaskFrequencyResponse `json:"frequency,omitempty"`
DueDate *time.Time `json:"due_date"` 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"` EstimatedCost *decimal.Decimal `json:"estimated_cost"`
ActualCost *decimal.Decimal `json:"actual_cost"` ActualCost *decimal.Decimal `json:"actual_cost"`
ContractorID *uint `json:"contractor_id"` ContractorID *uint `json:"contractor_id"`
@@ -229,7 +230,13 @@ func NewTaskCompletionResponse(c *models.TaskCompletion) TaskCompletionResponse
} }
// NewTaskResponse creates a TaskResponse from a Task model // NewTaskResponse creates a TaskResponse from a Task model
// Always includes kanban_column using default 30-day threshold
func NewTaskResponse(t *models.Task) TaskResponse { 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{ resp := TaskResponse{
ID: t.ID, ID: t.ID,
ResidenceID: t.ResidenceID, ResidenceID: t.ResidenceID,
@@ -242,6 +249,7 @@ func NewTaskResponse(t *models.Task) TaskResponse {
FrequencyID: t.FrequencyID, FrequencyID: t.FrequencyID,
AssignedToID: t.AssignedToID, AssignedToID: t.AssignedToID,
DueDate: t.DueDate, DueDate: t.DueDate,
NextDueDate: t.NextDueDate,
EstimatedCost: t.EstimatedCost, EstimatedCost: t.EstimatedCost,
ActualCost: t.ActualCost, ActualCost: t.ActualCost,
ContractorID: t.ContractorID, ContractorID: t.ContractorID,
@@ -249,6 +257,7 @@ func NewTaskResponse(t *models.Task) TaskResponse {
IsArchived: t.IsArchived, IsArchived: t.IsArchived,
ParentTaskID: t.ParentTaskID, ParentTaskID: t.ParentTaskID,
CompletionCount: len(t.Completions), CompletionCount: len(t.Completions),
KanbanColumn: DetermineKanbanColumn(t, daysThreshold),
CreatedAt: t.CreatedAt, CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt, UpdatedAt: t.UpdatedAt,
} }
@@ -348,17 +357,23 @@ func NewTaskCompletionWithTaskResponse(c *models.TaskCompletion, task *models.Ta
resp := NewTaskCompletionResponse(c) resp := NewTaskCompletionResponse(c)
if task != nil { if task != nil {
taskResp := NewTaskResponse(task) taskResp := NewTaskResponseWithThreshold(task, daysThreshold)
taskResp.KanbanColumn = DetermineKanbanColumn(task, daysThreshold)
resp.Task = &taskResp resp.Task = &taskResp
} }
return resp return resp
} }
// DetermineKanbanColumn determines which kanban column a task belongs to // DetermineKanbanColumn determines which kanban column a task belongs to.
// Uses the same logic as task_repo.go GetKanbanData // 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 { 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 { if daysThreshold <= 0 {
daysThreshold = 30 // Default daysThreshold = 30 // Default
} }
@@ -366,31 +381,37 @@ func DetermineKanbanColumn(task *models.Task, daysThreshold int) string {
now := time.Now().UTC() now := time.Now().UTC()
threshold := now.AddDate(0, 0, daysThreshold) threshold := now.AddDate(0, 0, daysThreshold)
// Priority order (same as GetKanbanData): // Priority order (Chain of Responsibility):
// 1. Cancelled // 1. Cancelled (highest priority)
if task.IsCancelled { if task.IsCancelled {
return "cancelled_tasks" return "cancelled_tasks"
} }
// 2. Completed (has completions) // 2. Completed (one-time task with nil next_due_date and has completions)
if len(task.Completions) > 0 { if task.NextDueDate == nil && len(task.Completions) > 0 {
return "completed_tasks" return "completed_tasks"
} }
// 3. In Progress // 3. In Progress (status check)
if task.Status != nil && task.Status.Name == "In Progress" { if task.Status != nil && task.Status.Name == "In Progress" {
return "in_progress_tasks" return "in_progress_tasks"
} }
// 4. Due date based // 4. Overdue (next_due_date or due_date is in the past)
if task.DueDate != nil { effectiveDate := task.NextDueDate
if task.DueDate.Before(now) { if effectiveDate == nil {
effectiveDate = task.DueDate
}
if effectiveDate != nil {
if effectiveDate.Before(now) {
return "overdue_tasks" return "overdue_tasks"
} else if task.DueDate.Before(threshold) { }
// 5. Due Soon (within threshold)
if effectiveDate.Before(threshold) {
return "due_soon_tasks" return "due_soon_tasks"
} }
} }
// Default: upcoming // 6. Upcoming (default/fallback)
return "upcoming_tasks" return "upcoming_tasks"
} }

View File

@@ -85,6 +85,7 @@ type Task struct {
Frequency *TaskFrequency `gorm:"foreignKey:FrequencyID" json:"frequency,omitempty"` Frequency *TaskFrequency `gorm:"foreignKey:FrequencyID" json:"frequency,omitempty"`
DueDate *time.Time `gorm:"column:due_date;type:date;index" json:"due_date"` DueDate *time.Time `gorm:"column:due_date;type:date;index" json:"due_date"`
NextDueDate *time.Time `gorm:"column:next_due_date;type:date;index" json:"next_due_date"` // For recurring tasks, updated after each completion
EstimatedCost *decimal.Decimal `gorm:"column:estimated_cost;type:decimal(10,2)" json:"estimated_cost"` EstimatedCost *decimal.Decimal `gorm:"column:estimated_cost;type:decimal(10,2)" json:"estimated_cost"`
ActualCost *decimal.Decimal `gorm:"column:actual_cost;type:decimal(10,2)" json:"actual_cost"` ActualCost *decimal.Decimal `gorm:"column:actual_cost;type:decimal(10,2)" json:"actual_cost"`

View File

@@ -125,6 +125,80 @@ func (c *APNsClient) Send(ctx context.Context, tokens []string, title, message s
return nil return nil
} }
// SendWithCategory sends a push notification with iOS category for actionable notifications
func (c *APNsClient) SendWithCategory(ctx context.Context, tokens []string, title, message string, data map[string]string, categoryID string) error {
if len(tokens) == 0 {
return nil
}
// Build the notification payload with category
p := payload.NewPayload().
AlertTitle(title).
AlertBody(message).
Sound("default").
MutableContent().
Category(categoryID) // iOS category for actionable notifications
// Add custom data
for key, value := range data {
p.Custom(key, value)
}
var errors []error
successCount := 0
for _, deviceToken := range tokens {
notification := &apns2.Notification{
DeviceToken: deviceToken,
Topic: c.topic,
Payload: p,
Priority: apns2.PriorityHigh,
}
res, err := c.client.PushWithContext(ctx, notification)
if err != nil {
log.Error().
Err(err).
Str("token", truncateToken(deviceToken)).
Str("category", categoryID).
Msg("Failed to send APNs actionable notification")
errors = append(errors, fmt.Errorf("token %s: %w", truncateToken(deviceToken), err))
continue
}
if !res.Sent() {
log.Error().
Str("token", truncateToken(deviceToken)).
Str("reason", res.Reason).
Int("status", res.StatusCode).
Str("category", categoryID).
Msg("APNs actionable notification not sent")
errors = append(errors, fmt.Errorf("token %s: %s (status %d)", truncateToken(deviceToken), res.Reason, res.StatusCode))
continue
}
successCount++
log.Debug().
Str("token", truncateToken(deviceToken)).
Str("apns_id", res.ApnsID).
Str("category", categoryID).
Msg("APNs actionable notification sent successfully")
}
log.Info().
Int("total", len(tokens)).
Int("success", successCount).
Int("failed", len(errors)).
Str("category", categoryID).
Msg("APNs actionable batch send complete")
if len(errors) > 0 && successCount == 0 {
return fmt.Errorf("all APNs actionable notifications failed: %v", errors)
}
return nil
}
// truncateToken returns first 8 chars of token for logging // truncateToken returns first 8 chars of token for logging
func truncateToken(token string) string { func truncateToken(token string) string {
if len(token) > 8 { if len(token) > 8 {

View File

@@ -102,6 +102,33 @@ func (c *Client) IsAndroidEnabled() bool {
return c.fcm != nil return c.fcm != nil
} }
// SendActionableNotification sends notifications with action button support
// iOS receives a category for actionable notifications, Android handles actions via data payload
func (c *Client) SendActionableNotification(ctx context.Context, iosTokens, androidTokens []string, title, message string, data map[string]string, iosCategoryID string) error {
var lastErr error
if len(iosTokens) > 0 {
if c.apns == nil {
log.Warn().Msg("APNs client not initialized, skipping iOS actionable push")
} else {
if err := c.apns.SendWithCategory(ctx, iosTokens, title, message, data, iosCategoryID); err != nil {
log.Error().Err(err).Msg("Failed to send iOS actionable notifications")
lastErr = err
}
}
}
if len(androidTokens) > 0 {
// Android handles actions via data payload - existing send works
if err := c.SendToAndroid(ctx, androidTokens, title, message, data); err != nil {
log.Error().Err(err).Msg("Failed to send Android notifications")
lastErr = err
}
}
return lastErr
}
// HealthCheck checks if the push services are available // HealthCheck checks if the push services are available
func (c *Client) HealthCheck(ctx context.Context) error { func (c *Client) HealthCheck(ctx context.Context) error {
// For direct clients, we can't easily health check without sending a notification // For direct clients, we can't easily health check without sending a notification

View File

@@ -73,8 +73,9 @@ func (r *ContractorRepository) Create(contractor *models.Contractor) error {
} }
// Update updates a contractor // Update updates a contractor
// Uses Omit to exclude associations that could interfere with Save
func (r *ContractorRepository) Update(contractor *models.Contractor) error { func (r *ContractorRepository) Update(contractor *models.Contractor) error {
return r.db.Save(contractor).Error return r.db.Omit("CreatedBy", "Specialties", "Tasks", "Residence").Save(contractor).Error
} }
// Delete soft-deletes a contractor // Delete soft-deletes a contractor

View File

@@ -90,8 +90,9 @@ func (r *DocumentRepository) Create(document *models.Document) error {
} }
// Update updates a document // Update updates a document
// Uses Omit to exclude associations that could interfere with Save
func (r *DocumentRepository) Update(document *models.Document) error { func (r *DocumentRepository) Update(document *models.Document) error {
return r.db.Save(document).Error return r.db.Omit("CreatedBy", "Task", "Images", "Residence").Save(document).Error
} }
// Delete soft-deletes a document // Delete soft-deletes a document

View File

@@ -89,8 +89,9 @@ func (r *ResidenceRepository) Create(residence *models.Residence) error {
} }
// Update updates a residence // Update updates a residence
// Uses Omit to exclude associations that could interfere with Save
func (r *ResidenceRepository) Update(residence *models.Residence) error { func (r *ResidenceRepository) Update(residence *models.Residence) error {
return r.db.Save(residence).Error return r.db.Omit("Owner", "Users", "PropertyType").Save(residence).Error
} }
// Delete soft-deletes a residence by setting is_active to false // Delete soft-deletes a residence by setting is_active to false

View File

@@ -8,6 +8,14 @@ import (
"github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/models"
) )
// 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
@@ -83,8 +91,9 @@ func (r *TaskRepository) Create(task *models.Task) error {
} }
// Update updates a task // Update updates a task
// Uses Omit to exclude associations that shouldn't be updated via Save
func (r *TaskRepository) Update(task *models.Task) error { func (r *TaskRepository) Update(task *models.Task) error {
return r.db.Save(task).Error return r.db.Omit("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Status", "Frequency", "ParentTask", "Completions").Save(task).Error
} }
// Delete hard-deletes a task // Delete hard-deletes a task
@@ -167,8 +176,8 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo
continue continue
} }
// Check if completed (has completions) // Check if completed (one-time task with nil next_due_date)
if len(task.Completions) > 0 { if isTaskCompleted(&task) {
completed = append(completed, task) completed = append(completed, task)
continue continue
} }
@@ -179,17 +188,28 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo
continue continue
} }
// Check due date // Use next_due_date for categorization (this handles recurring tasks properly)
if task.DueDate != nil { if task.NextDueDate != nil {
if task.DueDate.Before(now) { if task.NextDueDate.Before(now) {
overdue = append(overdue, task) overdue = append(overdue, task)
} else if task.DueDate.Before(threshold) { } else if task.NextDueDate.Before(threshold) {
dueSoon = append(dueSoon, task) dueSoon = append(dueSoon, task)
} else { } else {
upcoming = append(upcoming, task) upcoming = append(upcoming, task)
} }
} else { } else {
upcoming = append(upcoming, task) // 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)
}
} }
} }
@@ -294,8 +314,8 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint,
continue continue
} }
// Check if completed (has completions) // Check if completed (one-time task with nil next_due_date)
if len(task.Completions) > 0 { if isTaskCompleted(&task) {
completed = append(completed, task) completed = append(completed, task)
continue continue
} }
@@ -306,17 +326,28 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint,
continue continue
} }
// Check due date // Use next_due_date for categorization (this handles recurring tasks properly)
if task.DueDate != nil { if task.NextDueDate != nil {
if task.DueDate.Before(now) { if task.NextDueDate.Before(now) {
overdue = append(overdue, task) overdue = append(overdue, task)
} else if task.DueDate.Before(threshold) { } else if task.NextDueDate.Before(threshold) {
dueSoon = append(dueSoon, task) dueSoon = append(dueSoon, task)
} else { } else {
upcoming = append(upcoming, task) upcoming = append(upcoming, task)
} }
} else { } else {
upcoming = append(upcoming, task) // 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)
}
} }
} }

View File

@@ -384,8 +384,8 @@ func TestKanbanBoard_CompletedTasksGoToCompletedColumn(t *testing.T) {
assert.Len(t, completedColumn.Tasks, 1) assert.Len(t, completedColumn.Tasks, 1)
assert.Equal(t, "Completed Task", completedColumn.Tasks[0].Title) assert.Equal(t, "Completed Task", completedColumn.Tasks[0].Title)
// Verify button types for completed column (view only) // Verify button types for completed column (read-only, no buttons)
assert.ElementsMatch(t, []string{"view"}, completedColumn.ButtonTypes) assert.ElementsMatch(t, []string{}, completedColumn.ButtonTypes)
} }
func TestKanbanBoard_InProgressTasksGoToInProgressColumn(t *testing.T) { func TestKanbanBoard_InProgressTasksGoToInProgressColumn(t *testing.T) {
@@ -773,7 +773,7 @@ func TestKanbanBoard_ColumnMetadata(t *testing.T) {
{"in_progress_tasks", "In Progress", "#5856D6", []string{"edit", "complete", "cancel"}, "hammer", "Build"}, {"in_progress_tasks", "In Progress", "#5856D6", []string{"edit", "complete", "cancel"}, "hammer", "Build"},
{"due_soon_tasks", "Due Soon", "#FF9500", []string{"edit", "complete", "cancel", "mark_in_progress"}, "clock", "Schedule"}, {"due_soon_tasks", "Due Soon", "#FF9500", []string{"edit", "complete", "cancel", "mark_in_progress"}, "clock", "Schedule"},
{"upcoming_tasks", "Upcoming", "#007AFF", []string{"edit", "complete", "cancel", "mark_in_progress"}, "calendar", "Event"}, {"upcoming_tasks", "Upcoming", "#007AFF", []string{"edit", "complete", "cancel", "mark_in_progress"}, "calendar", "Event"},
{"completed_tasks", "Completed", "#34C759", []string{"view"}, "checkmark.circle", "CheckCircle"}, {"completed_tasks", "Completed", "#34C759", []string{}, "checkmark.circle", "CheckCircle"}, // Completed tasks are read-only (no buttons)
{"cancelled_tasks", "Cancelled", "#8E8E93", []string{"uncancel", "delete"}, "xmark.circle", "Cancel"}, {"cancelled_tasks", "Cancelled", "#8E8E93", []string{"uncancel", "delete"}, "xmark.circle", "Cancel"},
} }

View File

@@ -437,3 +437,90 @@ type RegisterDeviceRequest struct {
RegistrationID string `json:"registration_id" binding:"required"` RegistrationID string `json:"registration_id" binding:"required"`
Platform string `json:"platform" binding:"required,oneof=ios android"` Platform string `json:"platform" binding:"required,oneof=ios android"`
} }
// === Task Notifications with Actions ===
// CreateAndSendTaskNotification creates and sends a task notification with actionable buttons
// The backend always sends full notification data - the client decides how to display
// based on its locally cached subscription status
func (s *NotificationService) CreateAndSendTaskNotification(
ctx context.Context,
userID uint,
notificationType models.NotificationType,
task *models.Task,
) error {
// Check user notification preferences
prefs, err := s.notificationRepo.GetOrCreatePreferences(userID)
if err != nil {
return err
}
if !s.isNotificationEnabled(prefs, notificationType) {
return nil // Skip silently
}
// Build notification content - always send full data
title := GetTaskNotificationTitle(notificationType)
body := task.Title
// Get button types and iOS category based on task state
buttonTypes := GetButtonTypesForTask(task, 30) // 30 days threshold
iosCategoryID := GetIOSCategoryForTask(task)
// Build data payload - always includes full task info
// Client decides what to display based on local subscription status
data := map[string]interface{}{
"task_id": task.ID,
"task_name": task.Title,
"residence_id": task.ResidenceID,
"type": string(notificationType),
"button_types": buttonTypes,
"ios_category": iosCategoryID,
}
// Create notification record
dataJSON, _ := json.Marshal(data)
notification := &models.Notification{
UserID: userID,
NotificationType: notificationType,
Title: title,
Body: body,
Data: string(dataJSON),
TaskID: &task.ID,
}
if err := s.notificationRepo.Create(notification); err != nil {
return err
}
// Get device tokens
iosTokens, androidTokens, err := s.notificationRepo.GetActiveTokensForUser(userID)
if err != nil {
return err
}
// Convert data for push payload
pushData := make(map[string]string)
for k, v := range data {
switch val := v.(type) {
case string:
pushData[k] = val
case uint:
pushData[k] = strconv.FormatUint(uint64(val), 10)
default:
jsonVal, _ := json.Marshal(val)
pushData[k] = string(jsonVal)
}
}
pushData["notification_id"] = strconv.FormatUint(uint64(notification.ID), 10)
// Send push notification with actionable support
if s.pushClient != nil {
err = s.pushClient.SendActionableNotification(ctx, iosTokens, androidTokens, title, body, pushData, iosCategoryID)
if err != nil {
s.notificationRepo.SetError(notification.ID, err.Error())
return err
}
}
return s.notificationRepo.MarkAsSent(notification.ID)
}

View File

@@ -0,0 +1,96 @@
package services
import (
"time"
"github.com/treytartt/casera-api/internal/models"
)
// iOS Notification Category Identifiers
const (
IOSCategoryTaskActionable = "TASK_ACTIONABLE" // overdue, due_soon, upcoming
IOSCategoryTaskInProgress = "TASK_IN_PROGRESS" // tasks in progress
IOSCategoryTaskCancelled = "TASK_CANCELLED" // cancelled tasks
IOSCategoryTaskCompleted = "TASK_COMPLETED" // completed tasks (read-only)
IOSCategoryTaskGeneric = "TASK_NOTIFICATION_GENERIC" // non-premium users
)
// GetButtonTypesForTask returns the appropriate button_types for a task
// This reuses the same categorization logic as GetKanbanData in task_repo.go
func GetButtonTypesForTask(task *models.Task, daysThreshold int) []string {
now := time.Now().UTC()
threshold := now.AddDate(0, 0, daysThreshold)
// Priority order matches kanban logic
if task.IsCancelled {
return []string{"uncancel", "delete"}
}
// Check if task is "completed" (one-time task with nil next_due_date)
if isTaskCompleted(task) {
return []string{} // read-only
}
if task.Status != nil && task.Status.Name == "In Progress" {
return []string{"edit", "complete", "cancel"}
}
// Use next_due_date for categorization (handles recurring tasks properly)
if task.NextDueDate != nil {
if task.NextDueDate.Before(now) {
// Overdue
return []string{"edit", "complete", "cancel", "mark_in_progress"}
} else if task.NextDueDate.Before(threshold) {
// Due Soon
return []string{"edit", "complete", "cancel", "mark_in_progress"}
}
} else if task.DueDate != nil {
// Fallback to due_date if next_due_date not set yet
if task.DueDate.Before(now) {
return []string{"edit", "complete", "cancel", "mark_in_progress"}
} else if task.DueDate.Before(threshold) {
return []string{"edit", "complete", "cancel", "mark_in_progress"}
}
}
// Upcoming (default for tasks with future due dates or no due date)
return []string{"edit", "complete", "cancel", "mark_in_progress"}
}
// isTaskCompleted determines if a task should be considered "completed" for kanban display.
// A task is completed if next_due_date is nil (meaning it was a one-time task that's been completed).
// Recurring tasks always have a next_due_date after completion, so they're never "completed" permanently.
func isTaskCompleted(task *models.Task) bool {
// If next_due_date is nil and task has completions, it's a completed one-time task
return task.NextDueDate == nil && len(task.Completions) > 0
}
// GetIOSCategoryForTask returns the iOS notification category identifier
func GetIOSCategoryForTask(task *models.Task) string {
if task.IsCancelled {
return IOSCategoryTaskCancelled
}
if isTaskCompleted(task) {
return IOSCategoryTaskCompleted
}
if task.Status != nil && task.Status.Name == "In Progress" {
return IOSCategoryTaskInProgress
}
return IOSCategoryTaskActionable
}
// GetTaskNotificationTitle returns the notification title for a task notification type
func GetTaskNotificationTitle(notificationType models.NotificationType) string {
switch notificationType {
case models.NotificationTaskDueSoon:
return "Task Due Soon"
case models.NotificationTaskOverdue:
return "Task Overdue"
case models.NotificationTaskCompleted:
return "Task Completed"
case models.NotificationTaskAssigned:
return "Task Assigned"
default:
return "Task Update"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@ package services
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"time" "time"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@@ -142,6 +141,7 @@ func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) (
return nil, ErrResidenceAccessDenied return nil, ErrResidenceAccessDenied
} }
dueDate := req.DueDate.ToTimePtr()
task := &models.Task{ task := &models.Task{
ResidenceID: req.ResidenceID, ResidenceID: req.ResidenceID,
CreatedByID: userID, CreatedByID: userID,
@@ -152,7 +152,8 @@ func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) (
StatusID: req.StatusID, StatusID: req.StatusID,
FrequencyID: req.FrequencyID, FrequencyID: req.FrequencyID,
AssignedToID: req.AssignedToID, AssignedToID: req.AssignedToID,
DueDate: req.DueDate.ToTimePtr(), DueDate: dueDate,
NextDueDate: dueDate, // Initialize next_due_date to due_date
EstimatedCost: req.EstimatedCost, EstimatedCost: req.EstimatedCost,
ContractorID: req.ContractorID, ContractorID: req.ContractorID,
} }
@@ -213,7 +214,13 @@ func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRe
task.AssignedToID = req.AssignedToID task.AssignedToID = req.AssignedToID
} }
if req.DueDate != nil { if req.DueDate != nil {
task.DueDate = req.DueDate.ToTimePtr() newDueDate := req.DueDate.ToTimePtr()
task.DueDate = newDueDate
// Also update NextDueDate if the task doesn't have completions yet
// (if it has completions, NextDueDate should be managed by completion logic)
if len(task.Completions) == 0 {
task.NextDueDate = newDueDate
}
} }
if req.EstimatedCost != nil { if req.EstimatedCost != nil {
task.EstimatedCost = req.EstimatedCost task.EstimatedCost = req.EstimatedCost
@@ -482,6 +489,27 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
return nil, err return nil, err
} }
// Update next_due_date and status based on frequency
// - If frequency is "Once" (days = nil or 0), set next_due_date to nil
// - If frequency is recurring, calculate next_due_date = completion_date + frequency_days
// and reset status to "Pending" so task shows in correct kanban column
if task.Frequency == nil || task.Frequency.Days == nil || *task.Frequency.Days == 0 {
// One-time task - clear next_due_date since it's completed
task.NextDueDate = nil
} else {
// Recurring task - calculate next due date from completion date + frequency
nextDue := completedAt.AddDate(0, 0, *task.Frequency.Days)
task.NextDueDate = &nextDue
// Reset status to "Pending" (ID=1) so task appears in upcoming/due_soon
// instead of staying in "In Progress" column
pendingStatusID := uint(1)
task.StatusID = &pendingStatusID
}
if err := s.taskRepo.Update(task); err != nil {
log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to update task after completion")
}
// Create images if provided // Create images if provided
for _, imageURL := range req.ImageURLs { for _, imageURL := range req.ImageURLs {
if imageURL != "" { if imageURL != "" {
@@ -539,15 +567,6 @@ func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completio
completedByName = completion.CompletedBy.GetFullName() completedByName = completion.CompletedBy.GetFullName()
} }
title := "Task Completed"
body := fmt.Sprintf("%s completed: %s", completedByName, task.Title)
data := map[string]interface{}{
"task_id": task.ID,
"residence_id": task.ResidenceID,
"completion_id": completion.ID,
}
// Notify all users // Notify all users
for _, user := range users { for _, user := range users {
isCompleter := user.ID == completion.CompletedByID isCompleter := user.ID == completion.CompletedByID
@@ -556,13 +575,11 @@ func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completio
if !isCompleter && s.notificationService != nil { if !isCompleter && s.notificationService != nil {
go func(userID uint) { go func(userID uint) {
ctx := context.Background() ctx := context.Background()
if err := s.notificationService.CreateAndSendNotification( if err := s.notificationService.CreateAndSendTaskNotification(
ctx, ctx,
userID, userID,
models.NotificationTaskCompleted, models.NotificationTaskCompleted,
title, task,
body,
data,
); err != nil { ); err != nil {
log.Error().Err(err).Uint("user_id", userID).Uint("task_id", task.ID).Msg("Failed to send task completion push notification") log.Error().Err(err).Uint("user_id", userID).Uint("task_id", task.ID).Msg("Failed to send task completion push notification")
} }

View File

@@ -324,6 +324,67 @@ func TestTaskService_CreateCompletion(t *testing.T) {
assert.Equal(t, "Completed successfully", resp.Notes) assert.Equal(t, "Completed successfully", resp.Notes)
} }
func TestTaskService_CreateCompletion_RecurringTask_ResetsStatusToPending(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Get the "In Progress" status (ID=2) and a recurring frequency
var inProgressStatus models.TaskStatus
db.Where("name = ?", "In Progress").First(&inProgressStatus)
var monthlyFrequency models.TaskFrequency
db.Where("name = ?", "Monthly").First(&monthlyFrequency)
// Create a recurring task with "In Progress" status
dueDate := time.Now().AddDate(0, 0, 7) // Due in 7 days
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Recurring Task",
StatusID: &inProgressStatus.ID,
FrequencyID: &monthlyFrequency.ID,
DueDate: &dueDate,
NextDueDate: &dueDate,
IsCancelled: false,
IsArchived: false,
}
err := db.Create(task).Error
require.NoError(t, err)
// Complete the task
req := &requests.CreateTaskCompletionRequest{
TaskID: task.ID,
Notes: "Monthly maintenance done",
}
resp, err := service.CreateCompletion(req, user.ID)
require.NoError(t, err)
assert.NotZero(t, resp.ID)
// Verify the task in the response has status reset to "Pending" (ID=1)
require.NotNil(t, resp.Task, "Response should include the updated task")
require.NotNil(t, resp.Task.StatusID, "Task should have a status ID")
assert.Equal(t, uint(1), *resp.Task.StatusID, "Recurring task status should be reset to Pending (ID=1) after completion")
// Verify NextDueDate was updated (should be ~30 days from now for monthly)
require.NotNil(t, resp.Task.NextDueDate, "Recurring task should have NextDueDate set")
expectedNextDue := time.Now().AddDate(0, 0, 30) // Monthly = 30 days
assert.WithinDuration(t, expectedNextDue, *resp.Task.NextDueDate, 24*time.Hour, "NextDueDate should be approximately 30 days from now")
// Also verify by reloading from database directly
var reloadedTask models.Task
db.Preload("Status").First(&reloadedTask, task.ID)
require.NotNil(t, reloadedTask.StatusID)
assert.Equal(t, uint(1), *reloadedTask.StatusID, "Database should show Pending status")
assert.Equal(t, "Pending", reloadedTask.Status.Name)
}
func TestTaskService_GetCompletion(t *testing.T) { func TestTaskService_GetCompletion(t *testing.T) {
db := testutil.SetupTestDB(t) db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db) testutil.SeedLookupData(t, db)

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
}

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

View File

@@ -200,7 +200,7 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
t.id as task_id, t.id as task_id,
t.title as task_title, t.title as task_title,
t.due_date, t.due_date,
EXTRACT(DAY FROM ? - t.due_date)::int as days_overdue, EXTRACT(DAY FROM ?::timestamp - t.due_date)::int as days_overdue,
COALESCE(t.assigned_to_id, r.owner_id) as user_id, COALESCE(t.assigned_to_id, r.owner_id) as user_id,
r.name as residence_name r.name as residence_name
FROM task_task t FROM task_task t

View File

@@ -191,102 +191,125 @@ ON CONFLICT DO NOTHING;
-- Statuses: 1=Pending, 2=In Progress, 3=Completed, 4=On Hold, 5=Cancelled -- Statuses: 1=Pending, 2=In Progress, 3=Completed, 4=On Hold, 5=Cancelled
-- Frequencies: 1=Once, 2=Daily, 3=Weekly, 4=Bi-Weekly, 5=Monthly, 6=Quarterly, 7=Semi-Annual, 8=Annual -- Frequencies: 1=Once, 2=Daily, 3=Weekly, 4=Bi-Weekly, 5=Monthly, 6=Quarterly, 7=Semi-Annual, 8=Annual
-- ===================================================== -- =====================================================
INSERT INTO task_task (id, created_at, updated_at, residence_id, created_by_id, assigned_to_id, title, description, category_id, priority_id, status_id, frequency_id, due_date, estimated_cost, contractor_id, is_cancelled, is_archived) -- IMPORTANT: For recurring tasks (frequency > 1) that have completions, we must set:
-- - status_id = 1 (Pending) - so they appear in date-based columns, not "Completed"
-- - next_due_date = completion_date + frequency_days
-- Only one-time tasks (frequency_id = 1) with completions should have status_id = 3 and next_due_date = NULL
INSERT INTO task_task (id, created_at, updated_at, residence_id, created_by_id, assigned_to_id, title, description, category_id, priority_id, status_id, frequency_id, due_date, next_due_date, estimated_cost, contractor_id, is_cancelled, is_archived)
VALUES VALUES
-- ===== RESIDENCE 1 (John's Main Home) - 15 tasks ===== -- ===== RESIDENCE 1 (John's Main Home) - 15 tasks =====
-- Plumbing tasks -- Plumbing tasks
(1, NOW() - INTERVAL '30 days', NOW(), 1, 2, 2, 'Fix leaky kitchen faucet', 'Kitchen faucet has been dripping for a week. Washer may need replacement.', 1, 2, 1, 1, CURRENT_DATE + INTERVAL '7 days', 150.00, 1, false, false), (1, NOW() - INTERVAL '30 days', NOW(), 1, 2, 2, 'Fix leaky kitchen faucet', 'Kitchen faucet has been dripping for a week. Washer may need replacement.', 1, 2, 1, 1, CURRENT_DATE + INTERVAL '7 days', NULL, 150.00, 1, false, false),
(2, NOW() - INTERVAL '60 days', NOW(), 1, 2, 3, 'Unclog bathroom drain', 'Master bathroom sink draining slowly. Tried Drano with no success.', 1, 3, 3, 1, CURRENT_DATE - INTERVAL '45 days', 85.00, 1, false, false), (2, NOW() - INTERVAL '60 days', NOW(), 1, 2, 3, 'Unclog bathroom drain', 'Master bathroom sink draining slowly. Tried Drano with no success.', 1, 3, 3, 1, CURRENT_DATE - INTERVAL '45 days', NULL, 85.00, 1, false, false),
(3, NOW() - INTERVAL '15 days', NOW(), 1, 2, NULL, 'Water heater inspection', 'Annual inspection and flush of water heater tank', 1, 1, 1, 8, CURRENT_DATE + INTERVAL '30 days', 175.00, 1, false, false), (3, NOW() - INTERVAL '15 days', NOW(), 1, 2, NULL, 'Water heater inspection', 'Annual inspection and flush of water heater tank', 1, 1, 1, 8, CURRENT_DATE + INTERVAL '30 days', NULL, 175.00, 1, false, false),
-- Electrical tasks -- Electrical tasks
(4, NOW() - INTERVAL '20 days', NOW(), 1, 2, 2, 'Install ceiling fan', 'Replace light fixture in master bedroom with ceiling fan', 2, 2, 2, 1, CURRENT_DATE + INTERVAL '14 days', 350.00, 2, false, false), (4, NOW() - INTERVAL '20 days', NOW(), 1, 2, 2, 'Install ceiling fan', 'Replace light fixture in master bedroom with ceiling fan', 2, 2, 2, 1, CURRENT_DATE + INTERVAL '14 days', NULL, 350.00, 2, false, false),
(5, NOW() - INTERVAL '90 days', NOW(), 1, 2, NULL, 'Replace smoke detector batteries', 'Replace batteries in all 6 smoke detectors', 7, 3, 3, 8, CURRENT_DATE - INTERVAL '60 days', 30.00, NULL, false, false), -- Task 5: Annual recurring, completed 60 days ago -> next_due_date = completion + 365 days = ~305 days from now
(6, NOW() - INTERVAL '5 days', NOW(), 1, 2, NULL, 'Fix flickering lights', 'Living room lights flicker occasionally. May need new switch.', 2, 2, 1, 1, CURRENT_DATE + INTERVAL '10 days', 125.00, 2, false, false), (5, NOW() - INTERVAL '90 days', NOW(), 1, 2, NULL, 'Replace smoke detector batteries', 'Replace batteries in all 6 smoke detectors', 7, 3, 1, 8, CURRENT_DATE - INTERVAL '60 days', CURRENT_DATE + INTERVAL '305 days', 30.00, NULL, false, false),
(6, NOW() - INTERVAL '5 days', NOW(), 1, 2, NULL, 'Fix flickering lights', 'Living room lights flicker occasionally. May need new switch.', 2, 2, 1, 1, CURRENT_DATE + INTERVAL '10 days', NULL, 125.00, 2, false, false),
-- HVAC tasks -- HVAC tasks
(7, NOW() - INTERVAL '45 days', NOW(), 1, 2, 2, 'Replace HVAC filters', 'Monthly filter replacement for central air system', 3, 2, 3, 5, CURRENT_DATE - INTERVAL '15 days', 45.00, 3, false, false), -- Task 7: Monthly recurring, completed 15 days ago -> next_due_date = completion + 30 days = ~15 days from now
(8, NOW() - INTERVAL '10 days', NOW(), 1, 2, NULL, 'Schedule AC tune-up', 'Pre-summer AC maintenance and coolant check', 3, 2, 1, 8, CURRENT_DATE + INTERVAL '60 days', 200.00, 3, false, false), (7, NOW() - INTERVAL '45 days', NOW(), 1, 2, 2, 'Replace HVAC filters', 'Monthly filter replacement for central air system', 3, 2, 1, 5, CURRENT_DATE - INTERVAL '15 days', CURRENT_DATE + INTERVAL '15 days', 45.00, 3, false, false),
(8, NOW() - INTERVAL '10 days', NOW(), 1, 2, NULL, 'Schedule AC tune-up', 'Pre-summer AC maintenance and coolant check', 3, 2, 1, 8, CURRENT_DATE + INTERVAL '60 days', NULL, 200.00, 3, false, false),
-- Outdoor/Landscaping tasks -- Outdoor/Landscaping tasks
(9, NOW() - INTERVAL '7 days', NOW(), 1, 2, 3, 'Mow lawn', 'Weekly lawn mowing and edging', 5, 1, 3, 3, CURRENT_DATE - INTERVAL '2 days', 50.00, 5, false, false), -- Task 9: Weekly recurring, completed 2 days ago -> next_due_date = completion + 7 days = ~5 days from now
(10, NOW() - INTERVAL '25 days', NOW(), 1, 2, NULL, 'Trim hedges', 'Trim front and back yard hedges', 5, 1, 1, 6, CURRENT_DATE + INTERVAL '20 days', 150.00, 5, false, false), (9, NOW() - INTERVAL '7 days', NOW(), 1, 2, 3, 'Mow lawn', 'Weekly lawn mowing and edging', 5, 1, 1, 3, CURRENT_DATE - INTERVAL '2 days', CURRENT_DATE + INTERVAL '5 days', 50.00, 5, false, false),
(11, NOW() - INTERVAL '100 days', NOW(), 1, 2, 5, 'Clean gutters', 'Remove leaves and debris from all gutters', 5, 2, 3, 7, CURRENT_DATE - INTERVAL '70 days', 175.00, NULL, false, false), (10, NOW() - INTERVAL '25 days', NOW(), 1, 2, NULL, 'Trim hedges', 'Trim front and back yard hedges', 5, 1, 1, 6, CURRENT_DATE + INTERVAL '20 days', NULL, 150.00, 5, false, false),
(12, NOW() - INTERVAL '3 days', NOW(), 1, 2, NULL, 'Fertilize lawn', 'Apply spring fertilizer treatment', 5, 1, 1, 6, CURRENT_DATE + INTERVAL '5 days', 75.00, 5, false, false), -- Task 11: Semi-annual recurring, completed 70 days ago -> next_due_date = completion + 180 days = ~110 days from now
(11, NOW() - INTERVAL '100 days', NOW(), 1, 2, 5, 'Clean gutters', 'Remove leaves and debris from all gutters', 5, 2, 1, 7, CURRENT_DATE - INTERVAL '70 days', CURRENT_DATE + INTERVAL '110 days', 175.00, NULL, false, false),
(12, NOW() - INTERVAL '3 days', NOW(), 1, 2, NULL, 'Fertilize lawn', 'Apply spring fertilizer treatment', 5, 1, 1, 6, CURRENT_DATE + INTERVAL '5 days', NULL, 75.00, 5, false, false),
-- Safety tasks -- Safety tasks
(13, NOW() - INTERVAL '180 days', NOW(), 1, 2, 2, 'Test fire extinguishers', 'Annual inspection of all fire extinguishers', 7, 3, 3, 8, CURRENT_DATE - INTERVAL '150 days', 0.00, NULL, false, false), -- Task 13: Annual recurring, completed 150 days ago -> next_due_date = completion + 365 days = ~215 days from now
(13, NOW() - INTERVAL '180 days', NOW(), 1, 2, 2, 'Test fire extinguishers', 'Annual inspection of all fire extinguishers', 7, 3, 1, 8, CURRENT_DATE - INTERVAL '150 days', CURRENT_DATE + INTERVAL '215 days', 0.00, NULL, false, false),
-- Cleaning tasks -- Cleaning tasks
(14, NOW() - INTERVAL '40 days', NOW(), 1, 2, 3, 'Deep clean carpets', 'Professional carpet cleaning for all rooms', 8, 1, 3, 8, CURRENT_DATE - INTERVAL '25 days', 350.00, NULL, false, false), -- Task 14: Annual recurring, completed 25 days ago -> next_due_date = completion + 365 days = ~340 days from now
(14, NOW() - INTERVAL '40 days', NOW(), 1, 2, 3, 'Deep clean carpets', 'Professional carpet cleaning for all rooms', 8, 1, 1, 8, CURRENT_DATE - INTERVAL '25 days', CURRENT_DATE + INTERVAL '340 days', 350.00, NULL, false, false),
-- On hold task -- On hold task
(15, NOW() - INTERVAL '50 days', NOW(), 1, 2, NULL, 'Paint exterior', 'Repaint exterior trim and shutters', 6, 2, 4, 1, CURRENT_DATE + INTERVAL '90 days', 2500.00, NULL, false, false), (15, NOW() - INTERVAL '50 days', NOW(), 1, 2, NULL, 'Paint exterior', 'Repaint exterior trim and shutters', 6, 2, 4, 1, CURRENT_DATE + INTERVAL '90 days', NULL, 2500.00, NULL, false, false),
-- ===== RESIDENCE 2 (Beach House) - 8 tasks ===== -- ===== RESIDENCE 2 (Beach House) - 8 tasks =====
(16, NOW() - INTERVAL '20 days', NOW(), 2, 2, 2, 'Check pool chemicals', 'Weekly pool water testing and chemical balance', 10, 2, 3, 3, CURRENT_DATE - INTERVAL '6 days', 50.00, NULL, false, false), -- Task 16: Weekly recurring, completed 6 days ago -> next_due_date = completion + 7 days = ~1 day from now
(17, NOW() - INTERVAL '15 days', NOW(), 2, 2, NULL, 'Hurricane shutter inspection', 'Annual inspection before hurricane season', 7, 3, 1, 8, CURRENT_DATE + INTERVAL '45 days', 300.00, 7, false, false), (16, NOW() - INTERVAL '20 days', NOW(), 2, 2, 2, 'Check pool chemicals', 'Weekly pool water testing and chemical balance', 10, 2, 1, 3, CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE + INTERVAL '1 day', 50.00, NULL, false, false),
(18, NOW() - INTERVAL '30 days', NOW(), 2, 2, NULL, 'AC filter change', 'Replace AC filters - salt air requires more frequent changes', 3, 2, 1, 5, CURRENT_DATE + INTERVAL '5 days', 60.00, NULL, false, false), (17, NOW() - INTERVAL '15 days', NOW(), 2, 2, NULL, 'Hurricane shutter inspection', 'Annual inspection before hurricane season', 7, 3, 1, 8, CURRENT_DATE + INTERVAL '45 days', NULL, 300.00, 7, false, false),
(19, NOW() - INTERVAL '60 days', NOW(), 2, 2, 2, 'Fix outdoor shower', 'Outdoor shower has low pressure', 1, 2, 3, 1, CURRENT_DATE - INTERVAL '40 days', 200.00, 6, false, false), (18, NOW() - INTERVAL '30 days', NOW(), 2, 2, NULL, 'AC filter change', 'Replace AC filters - salt air requires more frequent changes', 3, 2, 1, 5, CURRENT_DATE + INTERVAL '5 days', NULL, 60.00, NULL, false, false),
(20, NOW() - INTERVAL '5 days', NOW(), 2, 2, NULL, 'Pressure wash deck', 'Clean salt buildup from deck and railings', 8, 1, 1, 6, CURRENT_DATE + INTERVAL '30 days', 250.00, NULL, false, false), (19, NOW() - INTERVAL '60 days', NOW(), 2, 2, 2, 'Fix outdoor shower', 'Outdoor shower has low pressure', 1, 2, 3, 1, CURRENT_DATE - INTERVAL '40 days', NULL, 200.00, 6, false, false),
(21, NOW() - INTERVAL '90 days', NOW(), 2, 2, NULL, 'Pest control treatment', 'Quarterly pest control for beach property', 9, 2, 3, 6, CURRENT_DATE - INTERVAL '60 days', 150.00, NULL, false, false), (20, NOW() - INTERVAL '5 days', NOW(), 2, 2, NULL, 'Pressure wash deck', 'Clean salt buildup from deck and railings', 8, 1, 1, 6, CURRENT_DATE + INTERVAL '30 days', NULL, 250.00, NULL, false, false),
(22, NOW() - INTERVAL '10 days', NOW(), 2, 2, NULL, 'Replace patio furniture', 'Sun-damaged furniture needs replacement', 10, 1, 4, 1, CURRENT_DATE + INTERVAL '60 days', 1200.00, NULL, false, false), -- Task 21: Quarterly recurring, completed 60 days ago -> next_due_date = completion + 90 days = ~30 days from now
(23, NOW() - INTERVAL '180 days', NOW(), 2, 2, 2, 'Install security cameras', 'Add 4 outdoor security cameras', 7, 2, 3, 1, CURRENT_DATE - INTERVAL '150 days', 800.00, NULL, false, false), (21, NOW() - INTERVAL '90 days', NOW(), 2, 2, NULL, 'Pest control treatment', 'Quarterly pest control for beach property', 9, 2, 1, 6, CURRENT_DATE - INTERVAL '60 days', CURRENT_DATE + INTERVAL '30 days', 150.00, NULL, false, false),
(22, NOW() - INTERVAL '10 days', NOW(), 2, 2, NULL, 'Replace patio furniture', 'Sun-damaged furniture needs replacement', 10, 1, 4, 1, CURRENT_DATE + INTERVAL '60 days', NULL, 1200.00, NULL, false, false),
(23, NOW() - INTERVAL '180 days', NOW(), 2, 2, 2, 'Install security cameras', 'Add 4 outdoor security cameras', 7, 2, 3, 1, CURRENT_DATE - INTERVAL '150 days', NULL, 800.00, NULL, false, false),
-- ===== RESIDENCE 3 (Investment Duplex) - 5 tasks ===== -- ===== RESIDENCE 3 (Investment Duplex) - 5 tasks =====
(24, NOW() - INTERVAL '25 days', NOW(), 3, 2, NULL, 'Unit A - Repair drywall', 'Patch and paint drywall damage from tenant', 6, 2, 1, 1, CURRENT_DATE + INTERVAL '14 days', 400.00, NULL, false, false), (24, NOW() - INTERVAL '25 days', NOW(), 3, 2, NULL, 'Unit A - Repair drywall', 'Patch and paint drywall damage from tenant', 6, 2, 1, 1, CURRENT_DATE + INTERVAL '14 days', NULL, 400.00, NULL, false, false),
(25, NOW() - INTERVAL '10 days', NOW(), 3, 2, NULL, 'Unit B - Replace dishwasher', 'Dishwasher not draining properly', 4, 3, 2, 1, CURRENT_DATE + INTERVAL '3 days', 650.00, NULL, false, false), (25, NOW() - INTERVAL '10 days', NOW(), 3, 2, NULL, 'Unit B - Replace dishwasher', 'Dishwasher not draining properly', 4, 3, 2, 1, CURRENT_DATE + INTERVAL '3 days', NULL, 650.00, NULL, false, false),
(26, NOW() - INTERVAL '45 days', NOW(), 3, 2, 2, 'Annual fire inspection', 'Required annual fire safety inspection', 7, 3, 3, 8, CURRENT_DATE - INTERVAL '30 days', 150.00, NULL, false, false), -- Task 26: Annual recurring, completed 30 days ago -> next_due_date = completion + 365 days = ~335 days from now
(27, NOW() - INTERVAL '5 days', NOW(), 3, 2, NULL, 'Replace hallway carpet', 'Common area carpet showing wear', 6, 1, 1, 1, CURRENT_DATE + INTERVAL '45 days', 1500.00, NULL, false, false), (26, NOW() - INTERVAL '45 days', NOW(), 3, 2, 2, 'Annual fire inspection', 'Required annual fire safety inspection', 7, 3, 1, 8, CURRENT_DATE - INTERVAL '30 days', CURRENT_DATE + INTERVAL '335 days', 150.00, NULL, false, false),
(28, NOW() - INTERVAL '200 days', NOW(), 3, 2, NULL, 'Roof inspection', 'Annual roof inspection', 6, 2, 3, 8, CURRENT_DATE - INTERVAL '170 days', 250.00, NULL, false, true), (27, NOW() - INTERVAL '5 days', NOW(), 3, 2, NULL, 'Replace hallway carpet', 'Common area carpet showing wear', 6, 1, 1, 1, CURRENT_DATE + INTERVAL '45 days', NULL, 1500.00, NULL, false, false),
-- Task 28: Annual recurring, archived, completed 170 days ago -> next_due_date = completion + 365 days = ~195 days from now
(28, NOW() - INTERVAL '200 days', NOW(), 3, 2, NULL, 'Roof inspection', 'Annual roof inspection', 6, 2, 1, 8, CURRENT_DATE - INTERVAL '170 days', CURRENT_DATE + INTERVAL '195 days', 250.00, NULL, false, true),
-- ===== RESIDENCE 4 (Jane's Downtown Loft) - 7 tasks ===== -- ===== RESIDENCE 4 (Jane's Downtown Loft) - 7 tasks =====
(29, NOW() - INTERVAL '15 days', NOW(), 4, 3, 3, 'Fix garbage disposal', 'Disposal is jammed and making noise', 4, 3, 2, 1, CURRENT_DATE + INTERVAL '2 days', 150.00, 8, false, false), (29, NOW() - INTERVAL '15 days', NOW(), 4, 3, 3, 'Fix garbage disposal', 'Disposal is jammed and making noise', 4, 3, 2, 1, CURRENT_DATE + INTERVAL '2 days', NULL, 150.00, 8, false, false),
(30, NOW() - INTERVAL '30 days', NOW(), 4, 3, NULL, 'Clean dryer vent', 'Annual dryer vent cleaning for fire safety', 7, 3, 1, 8, CURRENT_DATE + INTERVAL '30 days', 125.00, NULL, false, false), (30, NOW() - INTERVAL '30 days', NOW(), 4, 3, NULL, 'Clean dryer vent', 'Annual dryer vent cleaning for fire safety', 7, 3, 1, 8, CURRENT_DATE + INTERVAL '30 days', NULL, 125.00, NULL, false, false),
(31, NOW() - INTERVAL '60 days', NOW(), 4, 3, 3, 'Touch up paint', 'Touch up scuff marks on walls', 6, 1, 3, 1, CURRENT_DATE - INTERVAL '45 days', 0.00, NULL, false, false), (31, NOW() - INTERVAL '60 days', NOW(), 4, 3, 3, 'Touch up paint', 'Touch up scuff marks on walls', 6, 1, 3, 1, CURRENT_DATE - INTERVAL '45 days', NULL, 0.00, NULL, false, false),
(32, NOW() - INTERVAL '7 days', NOW(), 4, 3, NULL, 'Replace refrigerator water filter', 'Bi-annual filter replacement', 4, 1, 1, 7, CURRENT_DATE + INTERVAL '14 days', 50.00, 9, false, false), (32, NOW() - INTERVAL '7 days', NOW(), 4, 3, NULL, 'Replace refrigerator water filter', 'Bi-annual filter replacement', 4, 1, 1, 7, CURRENT_DATE + INTERVAL '14 days', NULL, 50.00, 9, false, false),
(33, NOW() - INTERVAL '90 days', NOW(), 4, 3, NULL, 'Deep clean oven', 'Professional oven cleaning', 8, 1, 3, 8, CURRENT_DATE - INTERVAL '60 days', 100.00, NULL, false, false), -- Task 33: Annual recurring, completed 60 days ago -> next_due_date = completion + 365 days = ~305 days from now
(34, NOW() - INTERVAL '120 days', NOW(), 4, 3, 3, 'Install smart thermostat', 'Replace old thermostat with Nest', 3, 2, 3, 1, CURRENT_DATE - INTERVAL '100 days', 250.00, NULL, false, false), (33, NOW() - INTERVAL '90 days', NOW(), 4, 3, NULL, 'Deep clean oven', 'Professional oven cleaning', 8, 1, 1, 8, CURRENT_DATE - INTERVAL '60 days', CURRENT_DATE + INTERVAL '305 days', 100.00, NULL, false, false),
(35, NOW() - INTERVAL '3 days', NOW(), 4, 3, NULL, 'Repair window blinds', 'Bedroom blinds cord is broken', 10, 1, 1, 1, CURRENT_DATE + INTERVAL '21 days', 75.00, NULL, false, false), (34, NOW() - INTERVAL '120 days', NOW(), 4, 3, 3, 'Install smart thermostat', 'Replace old thermostat with Nest', 3, 2, 3, 1, CURRENT_DATE - INTERVAL '100 days', NULL, 250.00, NULL, false, false),
(35, NOW() - INTERVAL '3 days', NOW(), 4, 3, NULL, 'Repair window blinds', 'Bedroom blinds cord is broken', 10, 1, 1, 1, CURRENT_DATE + INTERVAL '21 days', NULL, 75.00, NULL, false, false),
-- ===== RESIDENCE 5 (Bob's Mountain Condo) - 6 tasks ===== -- ===== RESIDENCE 5 (Bob's Mountain Condo) - 6 tasks =====
(36, NOW() - INTERVAL '20 days', NOW(), 5, 4, 4, 'Winterize pipes', 'Prepare plumbing for winter freeze', 1, 3, 3, 8, CURRENT_DATE - INTERVAL '10 days', 200.00, NULL, false, false), -- Task 36: Annual recurring, completed 10 days ago -> next_due_date = completion + 365 days = ~355 days from now
(37, NOW() - INTERVAL '40 days', NOW(), 5, 4, NULL, 'Check roof for snow damage', 'Inspect after heavy snowfall', 6, 3, 3, 1, CURRENT_DATE - INTERVAL '35 days', 0.00, 10, false, false), (36, NOW() - INTERVAL '20 days', NOW(), 5, 4, 4, 'Winterize pipes', 'Prepare plumbing for winter freeze', 1, 3, 1, 8, CURRENT_DATE - INTERVAL '10 days', CURRENT_DATE + INTERVAL '355 days', 200.00, NULL, false, false),
(38, NOW() - INTERVAL '10 days', NOW(), 5, 4, 12, 'Tune ski equipment storage', 'Organize ski room and check equipment', 10, 1, 2, 8, CURRENT_DATE + INTERVAL '7 days', 0.00, NULL, false, false), -- Task 37: One-time (frequency=1), completed -> stays completed with no next_due_date
(39, NOW() - INTERVAL '5 days', NOW(), 5, 4, NULL, 'Replace entry door weatherstrip', 'Cold air leaking around front door', 6, 2, 1, 1, CURRENT_DATE + INTERVAL '10 days', 100.00, NULL, false, false), (37, NOW() - INTERVAL '40 days', NOW(), 5, 4, NULL, 'Check roof for snow damage', 'Inspect after heavy snowfall', 6, 3, 3, 1, CURRENT_DATE - INTERVAL '35 days', NULL, 0.00, 10, false, false),
(40, NOW() - INTERVAL '90 days', NOW(), 5, 4, 4, 'HOA deck staining', 'Required deck maintenance per HOA', 5, 2, 3, 8, CURRENT_DATE - INTERVAL '60 days', 500.00, NULL, false, false), (38, NOW() - INTERVAL '10 days', NOW(), 5, 4, 12, 'Tune ski equipment storage', 'Organize ski room and check equipment', 10, 1, 2, 8, CURRENT_DATE + INTERVAL '7 days', NULL, 0.00, NULL, false, false),
(41, NOW() - INTERVAL '180 days', NOW(), 5, 4, NULL, 'Fireplace inspection', 'Annual chimney and fireplace check', 7, 3, 3, 8, CURRENT_DATE - INTERVAL '150 days', 175.00, NULL, false, false), (39, NOW() - INTERVAL '5 days', NOW(), 5, 4, NULL, 'Replace entry door weatherstrip', 'Cold air leaking around front door', 6, 2, 1, 1, CURRENT_DATE + INTERVAL '10 days', NULL, 100.00, NULL, false, false),
-- Task 40: Annual recurring, completed 60 days ago -> next_due_date = completion + 365 days = ~305 days from now
(40, NOW() - INTERVAL '90 days', NOW(), 5, 4, 4, 'HOA deck staining', 'Required deck maintenance per HOA', 5, 2, 1, 8, CURRENT_DATE - INTERVAL '60 days', CURRENT_DATE + INTERVAL '305 days', 500.00, NULL, false, false),
-- Task 41: Annual recurring, completed 150 days ago -> next_due_date = completion + 365 days = ~215 days from now
(41, NOW() - INTERVAL '180 days', NOW(), 5, 4, NULL, 'Fireplace inspection', 'Annual chimney and fireplace check', 7, 3, 1, 8, CURRENT_DATE - INTERVAL '150 days', CURRENT_DATE + INTERVAL '215 days', 175.00, NULL, false, false),
-- ===== RESIDENCE 8 (Alice's Craftsman) - 6 tasks ===== -- ===== RESIDENCE 8 (Alice's Craftsman) - 6 tasks =====
(42, NOW() - INTERVAL '25 days', NOW(), 8, 5, 5, 'Refinish hardwood floors', 'Sand and refinish original hardwood in living room', 6, 2, 2, 1, CURRENT_DATE + INTERVAL '30 days', 2000.00, 13, false, false), (42, NOW() - INTERVAL '25 days', NOW(), 8, 5, 5, 'Refinish hardwood floors', 'Sand and refinish original hardwood in living room', 6, 2, 2, 1, CURRENT_DATE + INTERVAL '30 days', NULL, 2000.00, 13, false, false),
(43, NOW() - INTERVAL '50 days', NOW(), 8, 5, 9, 'Repair original windows', 'Restore and weatherproof historic windows', 6, 2, 3, 1, CURRENT_DATE - INTERVAL '20 days', 1500.00, 13, false, false), (43, NOW() - INTERVAL '50 days', NOW(), 8, 5, 9, 'Repair original windows', 'Restore and weatherproof historic windows', 6, 2, 3, 1, CURRENT_DATE - INTERVAL '20 days', NULL, 1500.00, 13, false, false),
(44, NOW() - INTERVAL '15 days', NOW(), 8, 5, NULL, 'Update knob and tube wiring', 'Replace section of old wiring in attic', 2, 4, 1, 1, CURRENT_DATE + INTERVAL '14 days', 3000.00, NULL, false, false), (44, NOW() - INTERVAL '15 days', NOW(), 8, 5, NULL, 'Update knob and tube wiring', 'Replace section of old wiring in attic', 2, 4, 1, 1, CURRENT_DATE + INTERVAL '14 days', NULL, 3000.00, NULL, false, false),
(45, NOW() - INTERVAL '30 days', NOW(), 8, 5, 5, 'Garden maintenance', 'Weekly garden care and weeding', 5, 1, 3, 3, CURRENT_DATE - INTERVAL '7 days', 75.00, NULL, false, false), -- Task 45: Weekly recurring, completed 7 days ago -> next_due_date = completion + 7 days = today (due soon)
(46, NOW() - INTERVAL '7 days', NOW(), 8, 5, NULL, 'Fix porch swing', 'Chains need tightening and wood needs oiling', 10, 1, 1, 1, CURRENT_DATE + INTERVAL '21 days', 50.00, 13, false, false), (45, NOW() - INTERVAL '30 days', NOW(), 8, 5, 5, 'Garden maintenance', 'Weekly garden care and weeding', 5, 1, 1, 3, CURRENT_DATE - INTERVAL '7 days', CURRENT_DATE, 75.00, NULL, false, false),
(47, NOW() - INTERVAL '60 days', NOW(), 8, 5, 5, 'Clean rain gutters', 'Clear leaves from craftsman-style gutters', 5, 2, 3, 7, CURRENT_DATE - INTERVAL '30 days', 200.00, NULL, false, false), (46, NOW() - INTERVAL '7 days', NOW(), 8, 5, NULL, 'Fix porch swing', 'Chains need tightening and wood needs oiling', 10, 1, 1, 1, CURRENT_DATE + INTERVAL '21 days', NULL, 50.00, 13, false, false),
-- Task 47: Semi-annual recurring, completed 30 days ago -> next_due_date = completion + 180 days = ~150 days from now
(47, NOW() - INTERVAL '60 days', NOW(), 8, 5, 5, 'Clean rain gutters', 'Clear leaves from craftsman-style gutters', 5, 2, 1, 7, CURRENT_DATE - INTERVAL '30 days', CURRENT_DATE + INTERVAL '150 days', 200.00, NULL, false, false),
-- ===== RESIDENCE 11 (Edward's Fixer Upper) - 5 tasks ===== -- ===== RESIDENCE 11 (Edward's Fixer Upper) - 5 tasks =====
(48, NOW() - INTERVAL '14 days', NOW(), 11, 8, 8, 'Demo bathroom tile', 'Remove old tile in master bathroom', 6, 3, 2, 1, CURRENT_DATE + INTERVAL '7 days', 500.00, 15, false, false), (48, NOW() - INTERVAL '14 days', NOW(), 11, 8, 8, 'Demo bathroom tile', 'Remove old tile in master bathroom', 6, 3, 2, 1, CURRENT_DATE + INTERVAL '7 days', NULL, 500.00, 15, false, false),
(49, NOW() - INTERVAL '10 days', NOW(), 11, 8, NULL, 'Install new cabinets', 'Kitchen cabinet installation', 6, 3, 1, 1, CURRENT_DATE + INTERVAL '21 days', 5000.00, 16, false, false), (49, NOW() - INTERVAL '10 days', NOW(), 11, 8, NULL, 'Install new cabinets', 'Kitchen cabinet installation', 6, 3, 1, 1, CURRENT_DATE + INTERVAL '21 days', NULL, 5000.00, 16, false, false),
(50, NOW() - INTERVAL '7 days', NOW(), 11, 8, 8, 'Run new electrical', 'Additional outlets for kitchen', 2, 3, 2, 1, CURRENT_DATE + INTERVAL '14 days', 1200.00, NULL, false, false), (50, NOW() - INTERVAL '7 days', NOW(), 11, 8, 8, 'Run new electrical', 'Additional outlets for kitchen', 2, 3, 2, 1, CURRENT_DATE + INTERVAL '14 days', NULL, 1200.00, NULL, false, false),
(51, NOW() - INTERVAL '3 days', NOW(), 11, 8, NULL, 'Plumbing rough-in', 'Rough plumbing for bathroom remodel', 1, 3, 1, 1, CURRENT_DATE + INTERVAL '10 days', 2500.00, NULL, false, false), (51, NOW() - INTERVAL '3 days', NOW(), 11, 8, NULL, 'Plumbing rough-in', 'Rough plumbing for bathroom remodel', 1, 3, 1, 1, CURRENT_DATE + INTERVAL '10 days', NULL, 2500.00, NULL, false, false),
(52, NOW() - INTERVAL '1 day', NOW(), 11, 8, 8, 'Order new appliances', 'Select and order kitchen appliances', 4, 2, 2, 1, CURRENT_DATE + INTERVAL '5 days', 4000.00, NULL, false, false), (52, NOW() - INTERVAL '1 day', NOW(), 11, 8, 8, 'Order new appliances', 'Select and order kitchen appliances', 4, 2, 2, 1, CURRENT_DATE + INTERVAL '5 days', NULL, 4000.00, NULL, false, false),
-- ===== RESIDENCE 12 (Fiona's Studio) - 4 tasks ===== -- ===== RESIDENCE 12 (Fiona's Studio) - 4 tasks =====
(53, NOW() - INTERVAL '7 days', NOW(), 12, 9, 9, 'Setup smart lights', 'Install Philips Hue throughout studio', 2, 1, 3, 1, CURRENT_DATE - INTERVAL '3 days', 400.00, 17, false, false), (53, NOW() - INTERVAL '7 days', NOW(), 12, 9, 9, 'Setup smart lights', 'Install Philips Hue throughout studio', 2, 1, 3, 1, CURRENT_DATE - INTERVAL '3 days', NULL, 400.00, 17, false, false),
(54, NOW() - INTERVAL '5 days', NOW(), 12, 9, NULL, 'Mount TV on wall', 'Install TV mount and hide cables', 2, 1, 1, 1, CURRENT_DATE + INTERVAL '10 days', 200.00, 17, false, false), (54, NOW() - INTERVAL '5 days', NOW(), 12, 9, NULL, 'Mount TV on wall', 'Install TV mount and hide cables', 2, 1, 1, 1, CURRENT_DATE + INTERVAL '10 days', NULL, 200.00, 17, false, false),
(55, NOW() - INTERVAL '3 days', NOW(), 12, 9, 9, 'Organize closet', 'Install closet organization system', 10, 1, 2, 1, CURRENT_DATE + INTERVAL '7 days', 300.00, NULL, false, false), (55, NOW() - INTERVAL '3 days', NOW(), 12, 9, 9, 'Organize closet', 'Install closet organization system', 10, 1, 2, 1, CURRENT_DATE + INTERVAL '7 days', NULL, 300.00, NULL, false, false),
(56, NOW() - INTERVAL '1 day', NOW(), 12, 9, NULL, 'Replace faucet aerator', 'Low water pressure in bathroom sink', 1, 1, 1, 1, CURRENT_DATE + INTERVAL '14 days', 15.00, NULL, false, false), (56, NOW() - INTERVAL '1 day', NOW(), 12, 9, NULL, 'Replace faucet aerator', 'Low water pressure in bathroom sink', 1, 1, 1, 1, CURRENT_DATE + INTERVAL '14 days', NULL, 15.00, NULL, false, false),
-- ===== RESIDENCE 13 (George's Townhome) - 5 tasks ===== -- ===== RESIDENCE 13 (George's Townhome) - 5 tasks =====
(57, NOW() - INTERVAL '30 days', NOW(), 13, 12, 12, 'Service pool equipment', 'Quarterly pool pump and filter service', 10, 2, 3, 6, CURRENT_DATE - INTERVAL '15 days', 175.00, 19, false, false), -- Task 57: Quarterly recurring, completed 15 days ago -> next_due_date = completion + 90 days = ~75 days from now
(58, NOW() - INTERVAL '20 days', NOW(), 13, 12, NULL, 'HVAC maintenance', 'Pre-summer AC tune-up', 3, 2, 1, 8, CURRENT_DATE + INTERVAL '30 days', 200.00, 18, false, false), (57, NOW() - INTERVAL '30 days', NOW(), 13, 12, 12, 'Service pool equipment', 'Quarterly pool pump and filter service', 10, 2, 1, 6, CURRENT_DATE - INTERVAL '15 days', CURRENT_DATE + INTERVAL '75 days', 175.00, 19, false, false),
(59, NOW() - INTERVAL '10 days', NOW(), 13, 12, 12, 'Trim desert landscaping', 'Prune cacti and desert plants', 5, 1, 2, 6, CURRENT_DATE + INTERVAL '5 days', 100.00, NULL, false, false), (58, NOW() - INTERVAL '20 days', NOW(), 13, 12, NULL, 'HVAC maintenance', 'Pre-summer AC tune-up', 3, 2, 1, 8, CURRENT_DATE + INTERVAL '30 days', NULL, 200.00, 18, false, false),
(60, NOW() - INTERVAL '45 days', NOW(), 13, 12, 12, 'Check irrigation system', 'Test all drip irrigation zones', 5, 2, 3, 5, CURRENT_DATE - INTERVAL '30 days', 50.00, NULL, false, false), (59, NOW() - INTERVAL '10 days', NOW(), 13, 12, 12, 'Trim desert landscaping', 'Prune cacti and desert plants', 5, 1, 2, 6, CURRENT_DATE + INTERVAL '5 days', NULL, 100.00, NULL, false, false),
(61, NOW() - INTERVAL '5 days', NOW(), 13, 12, NULL, 'Replace garage door opener', 'Old opener failing', 4, 2, 1, 1, CURRENT_DATE + INTERVAL '14 days', 400.00, NULL, false, false), -- Task 60: Monthly recurring, completed 30 days ago -> next_due_date = completion + 30 days = today (due soon)
(60, NOW() - INTERVAL '45 days', NOW(), 13, 12, 12, 'Check irrigation system', 'Test all drip irrigation zones', 5, 2, 1, 5, CURRENT_DATE - INTERVAL '30 days', CURRENT_DATE, 50.00, NULL, false, false),
(61, NOW() - INTERVAL '5 days', NOW(), 13, 12, NULL, 'Replace garage door opener', 'Old opener failing', 4, 2, 1, 1, CURRENT_DATE + INTERVAL '14 days', NULL, 400.00, NULL, false, false),
-- Cancelled task -- Cancelled task
(62, NOW() - INTERVAL '60 days', NOW(), 1, 2, NULL, 'Build treehouse', 'Cancelled - tree not suitable', 6, 1, 5, 1, CURRENT_DATE - INTERVAL '30 days', 2000.00, NULL, true, false) (62, NOW() - INTERVAL '60 days', NOW(), 1, 2, NULL, 'Build treehouse', 'Cancelled - tree not suitable', 6, 1, 5, 1, CURRENT_DATE - INTERVAL '30 days', NULL, 2000.00, NULL, true, false)
ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, updated_at = NOW(); ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, updated_at = NOW();
-- ===================================================== -- =====================================================