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