This commit is contained in:
Trey t
2026-02-18 10:54:18 -06:00
parent a5245955af
commit 215e7c895d
11 changed files with 638 additions and 79 deletions

View File

@@ -1,6 +1,12 @@
{
"permissions": {
"allow": [
"WebSearch",
"WebFetch(domain:github.com)"
]
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"ios-simulator"
],
"enableAllProjectMcpServers": true
]
}

View File

@@ -0,0 +1,511 @@
'use client';
import { Clock, Mail, Bell, Calendar, Info, Timer, Repeat } from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
export default function AutomationReferencePage() {
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Automation Reference</h1>
<p className="text-muted-foreground">
Quick reference for all automated emails, notifications, and scheduled jobs
</p>
</div>
{/* Scheduled Jobs */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Scheduled Jobs (Cron)
</CardTitle>
<CardDescription>
Background jobs that run on a schedule. All times are in UTC.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Job</TableHead>
<TableHead>Schedule</TableHead>
<TableHead>Default Hour</TableHead>
<TableHead>Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">Task Reminders (Due Soon)</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono">0 20 * * *</Badge>
<span className="text-xs text-muted-foreground ml-2">Daily 8 PM UTC</span>
</TableCell>
<TableCell>
<span className="font-mono">20:00</span> (8 PM)
</TableCell>
<TableCell className="max-w-md">
<p className="text-sm">
Sends frequency-aware &quot;due soon&quot; task reminders. Reminder schedule varies by task frequency
(weekly: day-of only, monthly: 7 days + day-of, annual: 30/14/7 days + day-of).
</p>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Overdue Reminders</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono">0 9 * * *</Badge>
<span className="text-xs text-muted-foreground ml-2">Daily 9 AM UTC</span>
</TableCell>
<TableCell>
<span className="font-mono">09:00</span> (9 AM)
</TableCell>
<TableCell className="max-w-md">
<p className="text-sm">
Sends overdue task reminders with tapering: daily for days 1-3, every 3 days for days 4-14,
then stops to avoid notification fatigue.
</p>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Daily Digest</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono">0 11 * * *</Badge>
<span className="text-xs text-muted-foreground ml-2">Daily 11 AM UTC</span>
</TableCell>
<TableCell>
<span className="font-mono">11:00</span> (11 AM)
</TableCell>
<TableCell className="max-w-md">
<p className="text-sm">
Summary push notification with overdue count and tasks due this week.
Uses user&apos;s stored timezone for accurate overdue calculation.
</p>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Onboarding Emails</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono text-yellow-600">Not scheduled</Badge>
<span className="text-xs text-muted-foreground ml-2">Manual only</span>
</TableCell>
<TableCell>
<span className="text-muted-foreground">-</span>
</TableCell>
<TableCell className="max-w-md">
<p className="text-sm">
Handler exists but not registered in scheduler. Can be triggered manually from admin.
Sends emails to users who haven&apos;t created residences (2+ days) or tasks (5+ days).
</p>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
{/* Push Notification Types */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Push Notification Types
</CardTitle>
<CardDescription>
Types of push notifications and when they are sent
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Trigger</TableHead>
<TableHead>User Preference</TableHead>
<TableHead>Custom Time Field</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">
<Badge>task_due_soon</Badge>
</TableCell>
<TableCell>Smart Reminder job (tasks due within 2 days)</TableCell>
<TableCell><code>task_due_soon</code></TableCell>
<TableCell><code>task_due_soon_hour</code></TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">
<Badge variant="destructive">task_overdue</Badge>
</TableCell>
<TableCell>Smart Reminder job (past due date)</TableCell>
<TableCell><code>task_overdue</code></TableCell>
<TableCell><code>task_overdue_hour</code></TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">
<Badge variant="secondary">task_completed</Badge>
</TableCell>
<TableCell>When another user completes a task</TableCell>
<TableCell><code>task_completed</code></TableCell>
<TableCell>-</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">
<Badge variant="secondary">task_assigned</Badge>
</TableCell>
<TableCell>When assigned to a task</TableCell>
<TableCell><code>task_assigned</code></TableCell>
<TableCell>-</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">
<Badge variant="secondary">residence_shared</Badge>
</TableCell>
<TableCell>When a residence is shared with user</TableCell>
<TableCell><code>residence_shared</code></TableCell>
<TableCell>-</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">
<Badge variant="outline">warranty_expiring</Badge>
</TableCell>
<TableCell>Document warranty expiring (not yet implemented)</TableCell>
<TableCell><code>warranty_expiring</code></TableCell>
<TableCell><code>warranty_expiring_hour</code></TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">
<Badge className="bg-blue-500">daily_digest</Badge>
</TableCell>
<TableCell>Daily Digest job (summary notification)</TableCell>
<TableCell><code>daily_digest</code></TableCell>
<TableCell><code>daily_digest_hour</code></TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
{/* Email Templates */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Email Templates
</CardTitle>
<CardDescription>
All email templates and when they are sent
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="transactional" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="transactional" className="flex items-center gap-2">
<Timer className="h-4 w-4" />
Transactional Emails
</TabsTrigger>
<TabsTrigger value="onboarding" className="flex items-center gap-2">
<Repeat className="h-4 w-4" />
Onboarding Emails
</TabsTrigger>
</TabsList>
<TabsContent value="transactional" className="mt-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Subject</TableHead>
<TableHead>Trigger</TableHead>
<TableHead>Expiry</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">Welcome Email</TableCell>
<TableCell>Welcome to Casera - Verify Your Email</TableCell>
<TableCell>User registration (email/password)</TableCell>
<TableCell>24 hours (verification code)</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Apple Welcome</TableCell>
<TableCell>Welcome to Casera!</TableCell>
<TableCell>Apple Sign In registration</TableCell>
<TableCell>-</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Google Welcome</TableCell>
<TableCell>Welcome to Casera!</TableCell>
<TableCell>Google Sign In registration</TableCell>
<TableCell>-</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Post-Verification</TableCell>
<TableCell>You&apos;re All Set! Getting Started with Casera</TableCell>
<TableCell>After email verification</TableCell>
<TableCell>-</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Verification Email</TableCell>
<TableCell>Casera - Verify Your Email</TableCell>
<TableCell>Resend verification code</TableCell>
<TableCell>24 hours</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Password Reset</TableCell>
<TableCell>Casera - Password Reset Request</TableCell>
<TableCell>Password reset request</TableCell>
<TableCell>15 minutes</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Password Changed</TableCell>
<TableCell>Casera - Your Password Has Been Changed</TableCell>
<TableCell>After password change</TableCell>
<TableCell>-</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Task Completed</TableCell>
<TableCell>Casera - Task Completed: [Task Title]</TableCell>
<TableCell>When task is completed (if email_task_completed = true)</TableCell>
<TableCell>-</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Tasks Report</TableCell>
<TableCell>Casera - Tasks Report for [Residence]</TableCell>
<TableCell>User requests PDF export</TableCell>
<TableCell>-</TableCell>
</TableRow>
</TableBody>
</Table>
</TabsContent>
<TabsContent value="onboarding" className="mt-4">
<p className="text-sm text-muted-foreground mb-4">
Cron-triggered daily at 10:00 AM UTC. Each email type sent only once per user.
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead>Email Type</TableHead>
<TableHead>Subject</TableHead>
<TableHead>Criteria</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">
<Badge variant="outline">no_residence</Badge>
</TableCell>
<TableCell>Get started with Casera - Add your first property</TableCell>
<TableCell>2+ days since registration, no residence created</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">
<Badge variant="outline">no_tasks</Badge>
</TableCell>
<TableCell>Stay on top of home maintenance with Casera</TableCell>
<TableCell>5+ days since first residence, no tasks created</TableCell>
</TableRow>
</TableBody>
</Table>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* Default Configuration */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Scheduled Job Times
</CardTitle>
<CardDescription>
Hardcoded cron schedules in internal/worker/scheduler.go (all times UTC)
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Cron Expression</TableHead>
<TableHead>Hour (UTC)</TableHead>
<TableHead>Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-mono">Cron: 0 20 * * *</TableCell>
<TableCell>
<Badge variant="secondary">20</Badge>
<span className="text-xs text-muted-foreground ml-2">(8:00 PM UTC)</span>
</TableCell>
<TableCell>Task reminders (due soon) - hardcoded in scheduler.go</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono">Cron: 0 9 * * *</TableCell>
<TableCell>
<Badge variant="secondary">9</Badge>
<span className="text-xs text-muted-foreground ml-2">(9:00 AM UTC)</span>
</TableCell>
<TableCell>Overdue reminders - hardcoded in scheduler.go</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono">Cron: 0 11 * * *</TableCell>
<TableCell>
<Badge variant="secondary">11</Badge>
<span className="text-xs text-muted-foreground ml-2">(11:00 AM UTC)</span>
</TableCell>
<TableCell>Daily digest - hardcoded in scheduler.go</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
{/* Smart Reminder Logic */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Info className="h-5 w-5" />
Smart Reminder Logic
</CardTitle>
<CardDescription>
How the frequency-aware reminder system works
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border p-4 space-y-3">
<h4 className="font-medium">Reminder Stages by Frequency</h4>
<p className="text-xs text-muted-foreground mb-2">
Source: internal/notifications/reminder_config.go
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
<div>
<p className="font-medium text-muted-foreground">Once / Daily / Weekly:</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Day-of only</li>
</ul>
</div>
<div>
<p className="font-medium text-muted-foreground">Bi-Weekly (14d):</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>1 day before</li>
<li>Day-of</li>
</ul>
</div>
<div>
<p className="font-medium text-muted-foreground">Monthly (30d):</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>3 days before</li>
<li>1 day before</li>
<li>Day-of</li>
</ul>
</div>
<div>
<p className="font-medium text-muted-foreground">Quarterly (90d):</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>7 days before</li>
<li>3 days before</li>
<li>1 day before</li>
<li>Day-of</li>
</ul>
</div>
<div>
<p className="font-medium text-muted-foreground">Semi-Annually (180d):</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>14 days before</li>
<li>7 days before</li>
<li>3 days before</li>
<li>1 day before</li>
<li>Day-of</li>
</ul>
</div>
<div>
<p className="font-medium text-muted-foreground">Annually (365d):</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>30 days before</li>
<li>14 days before</li>
<li>7 days before</li>
<li>3 days before</li>
<li>1 day before</li>
<li>Day-of</li>
</ul>
</div>
</div>
</div>
<div className="rounded-lg border p-4 space-y-3">
<h4 className="font-medium">Overdue Reminder Tapering</h4>
<p className="text-sm text-muted-foreground">
Overdue reminders taper off to avoid notification fatigue (configured in reminder_config.go):
</p>
<ul className="list-disc list-inside text-sm space-y-1">
<li><strong>Days 1-3:</strong> Daily reminders (DailyReminderDays: 3)</li>
<li><strong>Days 4, 7, 10, 13:</strong> Every 3 days (TaperIntervalDays: 3)</li>
<li><strong>After 14 days:</strong> No more reminders (MaxOverdueDays: 14)</li>
</ul>
</div>
<div className="rounded-lg border p-4 space-y-3">
<h4 className="font-medium">Duplicate Prevention</h4>
<p className="text-sm text-muted-foreground">
Each reminder is tracked in the <code className="bg-muted px-1 rounded">task_reminder_logs</code> table
with task ID, user ID, due date, and stage. This prevents sending the same reminder twice.
Logs older than 90 days are automatically cleaned up.
</p>
</div>
</CardContent>
</Card>
{/* Timezone Handling */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Timezone Handling
</CardTitle>
<CardDescription>
How user timezones are captured and used
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border p-4 space-y-3">
<h4 className="font-medium">Auto-Capture</h4>
<p className="text-sm text-muted-foreground">
The mobile app sends an <code className="bg-muted px-1 rounded">X-Timezone</code> header
on every API request (IANA format, e.g., &quot;America/Los_Angeles&quot;).
The server captures this when the user fetches their tasks (happens on every app launch)
and stores it in the <code className="bg-muted px-1 rounded">timezone</code> column of
<code className="bg-muted px-1 rounded">notifications_notificationpreference</code>.
</p>
</div>
<div className="rounded-lg border p-4 space-y-3">
<h4 className="font-medium">Usage in Daily Digest</h4>
<p className="text-sm text-muted-foreground">
The Daily Digest job uses the stored timezone to calculate &quot;start of day&quot; in the user&apos;s
local time. This ensures the overdue task count matches what the user sees in the app&apos;s Kanban view.
If no timezone is stored, UTC is used as fallback.
</p>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -30,6 +30,7 @@ import {
Apple,
Image,
ImagePlus,
Cog,
} from 'lucide-react';
import { useRouter, usePathname } from 'next/navigation';
import { useAuthStore } from '@/store/auth';
@@ -77,6 +78,7 @@ const limitationsItems = [
const settingsItems = [
{ title: 'Monitoring', url: '/admin/monitoring', icon: Activity },
{ title: 'Automation Reference', url: '/admin/automation-reference', icon: Cog },
{ title: 'Lookup Tables', url: '/admin/lookups', icon: BookOpen },
{ title: 'Task Templates', url: '/admin/task-templates', icon: LayoutTemplate },
{ title: 'Admin Users', url: '/admin/admin-users', icon: UserCog },

View File

@@ -2,6 +2,9 @@
This document explains how tasks are categorized into kanban columns in the Casera application.
> Note: The categorization chain still computes `cancelled_tasks`, but the kanban board response
> intentionally hides cancelled/archived tasks and returns only 5 visible columns.
## 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.

View File

@@ -2,9 +2,12 @@
This document describes how tasks are categorized into kanban columns for display in the Casera mobile app.
> Important: The board intentionally returns **5 visible columns**. Cancelled and archived tasks are
> hidden from board responses (though task-level `kanban_column` may still be `cancelled_tasks`).
## Overview
Tasks are organized into 6 kanban columns based on their state and due date. The categorization logic is implemented in `internal/repositories/task_repo.go` in the `GetKanbanData` and `GetKanbanDataForMultipleResidences` functions.
Tasks are organized into 5 visible kanban columns based on their state and due date. The categorization logic is implemented in `internal/repositories/task_repo.go` in the `GetKanbanData` and `GetKanbanDataForMultipleResidences` functions.
## Columns Summary
@@ -15,7 +18,6 @@ Tasks are organized into 6 kanban columns based on their state and due date. The
| 3 | **Upcoming** | `#007AFF` (Blue) | edit, complete, cancel, mark_in_progress | Tasks due beyond the threshold or with no due date |
| 4 | **In Progress** | `#5856D6` (Purple) | edit, complete, cancel | Tasks with status "In Progress" |
| 5 | **Completed** | `#34C759` (Green) | view | Tasks with at least one completion record |
| 6 | **Cancelled** | `#8E8E93` (Gray) | uncancel, delete | Tasks marked as cancelled |
## Categorization Flow

View File

@@ -227,7 +227,7 @@ func TestTaskHandler_GetTasksByResidence(t *testing.T) {
assert.Contains(t, response, "residence_id")
columns := response["columns"].([]interface{})
assert.Len(t, columns, 6) // 6 kanban columns
assert.Len(t, columns, 5) // 5 visible kanban columns (cancelled/archived hidden)
})
t.Run("kanban column structure", func(t *testing.T) {

View File

@@ -7,6 +7,7 @@
"error.email_already_taken": "Email already taken",
"error.registration_failed": "Registration failed",
"error.not_authenticated": "Not authenticated",
"error.invalid_token": "Invalid token",
"error.failed_to_get_user": "Failed to get user",
"error.failed_to_update_profile": "Failed to update profile",
"error.invalid_verification_code": "Invalid verification code",

View File

@@ -943,7 +943,7 @@ func TestIntegration_ComprehensiveE2E(t *testing.T) {
{"Cancelled Task 1 - Build shed", 3, 15, "cancelled"},
{"Cancelled Task 2 - Install pool", 4, 60, "cancelled"},
// Archived tasks (should appear in cancelled column)
// Archived tasks (hidden from kanban board)
{"Archived Task 1 - Old project", 0, -30, "archived"},
{"Archived Task 2 - Deprecated work", 1, -20, "archived"},
@@ -1059,7 +1059,7 @@ func TestIntegration_ComprehensiveE2E(t *testing.T) {
var taskListResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &taskListResp)
// Count total tasks across all columns
// Count total visible tasks across all columns
totalTasks := 0
if columns, ok := taskListResp["columns"].([]interface{}); ok {
for _, col := range columns {
@@ -1069,7 +1069,13 @@ func TestIntegration_ComprehensiveE2E(t *testing.T) {
}
}
}
assert.Equal(t, 20, totalTasks, "Should have 20 total tasks")
expectedVisibleTasks := 0
for _, task := range createdTasks {
if task.ExpectedColumn != "" {
expectedVisibleTasks++
}
}
assert.Equal(t, expectedVisibleTasks, totalTasks, "Should have %d visible tasks", expectedVisibleTasks)
// Verify individual task retrieval
for _, task := range createdTasks {
@@ -1131,7 +1137,6 @@ func TestIntegration_ComprehensiveE2E(t *testing.T) {
"due_soon_tasks": false,
"upcoming_tasks": false,
"completed_tasks": false,
"cancelled_tasks": false,
}
for _, col := range columns {
@@ -1191,10 +1196,29 @@ func TestIntegration_ComprehensiveE2E(t *testing.T) {
}
}
// Verify EACH task by ID is in its expected column
// This catches swaps where counts match but tasks are in wrong columns
// Verify each task is in expected column (or hidden for cancelled/archived)
t.Log(" Verifying each task's column membership by ID:")
for _, task := range createdTasks {
if task.ExpectedColumn == "" {
found := false
for colName, ids := range columnTaskIDs {
for _, id := range ids {
if id == task.ID {
found = true
assert.Fail(t, "Hidden task unexpectedly visible",
"Task ID %d ('%s') should be hidden from kanban but appeared in '%s'",
task.ID, task.Title, colName)
break
}
}
}
assert.False(t, found, "Task ID %d ('%s') should be hidden from kanban", task.ID, task.Title)
if !found {
t.Logf(" ✓ Task %d ('%s') correctly hidden from board", task.ID, task.Title)
}
continue
}
actualIDs := columnTaskIDs[task.ExpectedColumn]
found := false
for _, id := range actualIDs {
@@ -1210,14 +1234,14 @@ func TestIntegration_ComprehensiveE2E(t *testing.T) {
}
}
// Verify total equals 20 (sanity check)
// Verify total equals expected visible tasks (sanity check)
total := 0
for _, ids := range columnTaskIDs {
total += len(ids)
}
assert.Equal(t, 20, total, "Total tasks across all columns should be 20")
assert.Equal(t, expectedVisibleTasks, total, "Total tasks across all columns should be %d", expectedVisibleTasks)
t.Log("✓ All 20 tasks verified in correct columns by ID")
t.Logf("✓ All %d visible tasks verified in correct columns by ID", expectedVisibleTasks)
// ============ Phase 9: Create User B ============
t.Log("Phase 9: Creating User B and verifying login")
@@ -1335,7 +1359,7 @@ func TestIntegration_ComprehensiveE2E(t *testing.T) {
// Count expected tasks for shared residence (residenceIndex=0 in our config)
expectedTasksForResidence := 0
for _, task := range createdTasks {
if task.ResidenceID == sharedResidenceID {
if task.ResidenceID == sharedResidenceID && task.ExpectedColumn != "" {
expectedTasksForResidence++
}
}
@@ -1409,7 +1433,7 @@ func TestIntegration_ComprehensiveE2E(t *testing.T) {
expectedColumnNames := []string{
"overdue_tasks", "in_progress_tasks", "due_soon_tasks",
"upcoming_tasks", "completed_tasks", "cancelled_tasks",
"upcoming_tasks", "completed_tasks",
}
for _, colName := range expectedColumnNames {
assert.True(t, foundColumns[colName], "User B should have column: %s", colName)
@@ -1530,16 +1554,16 @@ func TestIntegration_ComprehensiveE2E(t *testing.T) {
// based on its due date offset, status, and threshold
func determineExpectedColumn(daysFromNow int, status string, threshold int) string {
// This must match the categorization chain priority order:
// 1. Cancelled (priority 1)
// 2. Archived (priority 2)
// 3. Completed (priority 3)
// 4. InProgress (priority 4) - takes precedence over date-based columns!
// 5. Overdue (priority 5)
// 6. DueSoon (priority 6)
// 7. Upcoming (priority 7)
// Cancelled and archived tasks are intentionally hidden from kanban board view.
// Remaining visible columns follow:
// 1. Completed
// 2. InProgress (takes precedence over date-based columns)
// 3. Overdue
// 4. DueSoon
// 5. Upcoming
switch status {
case "cancelled", "archived":
return "cancelled_tasks"
return "" // Hidden from board
case "completed":
return "completed_tasks"
case "in_progress":

View File

@@ -346,7 +346,8 @@ func (r *TaskRepository) Unarchive(id uint) error {
// buildKanbanColumns builds the kanban column array from categorized task slices.
// This is a helper function to reduce duplication between GetKanbanData and GetKanbanDataForMultipleResidences.
// TEMPORARILY DISABLED: cancelled parameter removed - cancel column hidden from kanban
// Note: cancelled/archived tasks are intentionally hidden from the kanban board.
// They still retain "cancelled_tasks" as task-level categorization for detail views/actions.
func buildKanbanColumns(
overdue, inProgress, dueSoon, upcoming, completed []models.Task,
) []models.KanbanColumn {
@@ -396,7 +397,8 @@ func buildKanbanColumns(
Tasks: completed,
Count: len(completed),
},
// TEMPORARILY DISABLED - Cancel column hidden from kanban
// Intentionally hidden from board:
// cancelled/archived tasks are not returned as a kanban column.
// {
// Name: string(categorization.ColumnCancelled),
// DisplayName: "Cancelled",
@@ -451,7 +453,8 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int, now
return nil, fmt.Errorf("get completed tasks: %w", err)
}
// TEMPORARILY DISABLED - Cancel column hidden from kanban
// Intentionally hidden from board:
// cancelled/archived tasks are not returned as a kanban column.
// cancelled, err := r.GetCancelledTasks(opts)
// if err != nil {
// return nil, fmt.Errorf("get cancelled tasks: %w", err)
@@ -509,7 +512,8 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint,
return nil, fmt.Errorf("get completed tasks: %w", err)
}
// TEMPORARILY DISABLED - Cancel column hidden from kanban
// Intentionally hidden from board:
// cancelled/archived tasks are not returned as a kanban column.
// cancelled, err := r.GetCancelledTasks(opts)
// if err != nil {
// return nil, fmt.Errorf("get cancelled tasks: %w", err)

View File

@@ -306,7 +306,7 @@ func TestTaskRepository_CountByResidence(t *testing.T) {
// === Kanban Board Categorization Tests ===
func TestKanbanBoard_CancelledTasksGoToCancelledColumn(t *testing.T) {
func TestKanbanBoard_CancelledTasksHiddenFromKanbanBoard(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
@@ -321,22 +321,16 @@ func TestKanbanBoard_CancelledTasksGoToCancelledColumn(t *testing.T) {
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
// Find cancelled column
var cancelledColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "cancelled_tasks" {
cancelledColumn = &board.Columns[i]
break
}
assert.Len(t, board.Columns, 5, "board should have 5 visible columns")
for _, col := range board.Columns {
assert.NotEqual(t, "cancelled_tasks", col.Name, "cancelled column must be hidden")
}
require.NotNil(t, cancelledColumn, "cancelled_tasks column should exist")
assert.Equal(t, 1, cancelledColumn.Count)
assert.Len(t, cancelledColumn.Tasks, 1)
assert.Equal(t, "Cancelled Task", cancelledColumn.Tasks[0].Title)
// Verify button types for cancelled column
assert.ElementsMatch(t, []string{"uncancel", "delete"}, cancelledColumn.ButtonTypes)
totalTasks := 0
for _, col := range board.Columns {
totalTasks += col.Count
}
assert.Equal(t, 0, totalTasks, "cancelled task should be hidden from board")
}
func TestKanbanBoard_CompletedTasksGoToCompletedColumn(t *testing.T) {
@@ -566,7 +560,7 @@ func TestKanbanBoard_TasksWithNoDueDateGoToUpcomingColumn(t *testing.T) {
assert.Equal(t, task.ID, upcomingColumn.Tasks[0].ID)
}
func TestKanbanBoard_ArchivedTasksGoToCancelledColumn(t *testing.T) {
func TestKanbanBoard_ArchivedTasksHiddenFromKanbanBoard(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
@@ -582,38 +576,36 @@ func TestKanbanBoard_ArchivedTasksGoToCancelledColumn(t *testing.T) {
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
// Find the cancelled column and verify archived task is there
var cancelledColumn *models.KanbanColumn
// Find the upcoming column and verify archived task is hidden
var upcomingColumn *models.KanbanColumn
foundCancelledColumn := false
for i := range board.Columns {
if board.Columns[i].Name == "cancelled_tasks" {
cancelledColumn = &board.Columns[i]
foundCancelledColumn = true
}
if board.Columns[i].Name == "upcoming_tasks" {
upcomingColumn = &board.Columns[i]
}
}
require.NotNil(t, cancelledColumn, "cancelled_tasks column should exist")
require.NotNil(t, upcomingColumn, "upcoming_tasks column should exist")
assert.False(t, foundCancelledColumn, "cancelled column must be hidden")
// Archived task should be in the cancelled column
assert.Equal(t, 1, cancelledColumn.Count, "archived task should be in cancelled column")
assert.Equal(t, "Archived Task", cancelledColumn.Tasks[0].Title)
// Archived task should be hidden from board
// Regular task should be in upcoming (no due date)
assert.Equal(t, 1, upcomingColumn.Count, "regular task should be in upcoming column")
assert.Equal(t, "Regular Task", upcomingColumn.Tasks[0].Title)
// Total tasks should be 2 (both appear in the board)
// Total tasks should be 1 (archived task is hidden)
totalTasks := 0
for _, col := range board.Columns {
totalTasks += col.Count
}
assert.Equal(t, 2, totalTasks, "both tasks should appear in the board")
assert.Equal(t, 1, totalTasks, "archived task should be hidden from board")
}
func TestKanbanBoard_ArchivedOverdueTask_GoesToCancelledNotOverdue(t *testing.T) {
func TestKanbanBoard_ArchivedOverdueTask_HiddenFromKanbanBoard(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
@@ -638,26 +630,30 @@ func TestKanbanBoard_ArchivedOverdueTask_GoesToCancelledNotOverdue(t *testing.T)
require.NoError(t, err)
// Find columns
var cancelledColumn, overdueColumn *models.KanbanColumn
var overdueColumn *models.KanbanColumn
foundCancelledColumn := false
for i := range board.Columns {
if board.Columns[i].Name == "cancelled_tasks" {
cancelledColumn = &board.Columns[i]
foundCancelledColumn = true
}
if board.Columns[i].Name == "overdue_tasks" {
overdueColumn = &board.Columns[i]
}
}
require.NotNil(t, cancelledColumn)
require.NotNil(t, overdueColumn)
assert.False(t, foundCancelledColumn, "cancelled column must be hidden")
// Archived task should be in cancelled, NOT overdue
assert.Equal(t, 1, cancelledColumn.Count, "archived task should be in cancelled column")
// Archived task should be hidden and NOT overdue
assert.Equal(t, 0, overdueColumn.Count, "archived task should NOT be in overdue column")
assert.Equal(t, "Archived Overdue Task", cancelledColumn.Tasks[0].Title)
totalTasks := 0
for _, col := range board.Columns {
totalTasks += col.Count
}
assert.Equal(t, 0, totalTasks, "archived task should be hidden from board")
}
func TestKanbanBoard_CategoryPriority_CancelledTakesPrecedence(t *testing.T) {
func TestKanbanBoard_CategoryPriority_CancelledTasksAreHidden(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
@@ -681,21 +677,26 @@ func TestKanbanBoard_CategoryPriority_CancelledTakesPrecedence(t *testing.T) {
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
// Find cancelled column
var cancelledColumn *models.KanbanColumn
var overdueColumn *models.KanbanColumn
foundCancelledColumn := false
for i := range board.Columns {
if board.Columns[i].Name == "cancelled_tasks" {
cancelledColumn = &board.Columns[i]
foundCancelledColumn = true
}
if board.Columns[i].Name == "overdue_tasks" {
overdueColumn = &board.Columns[i]
}
}
// Task should be in cancelled, not overdue
assert.Equal(t, 1, cancelledColumn.Count, "Task should be in cancelled column")
// Cancelled task should be hidden and not appear as overdue
assert.False(t, foundCancelledColumn, "cancelled column must be hidden")
require.NotNil(t, overdueColumn, "overdue column should exist")
assert.Equal(t, 0, overdueColumn.Count, "Task should NOT be in overdue column")
totalTasks := 0
for _, col := range board.Columns {
totalTasks += col.Count
}
assert.Equal(t, 0, totalTasks, "cancelled task should be hidden from board")
}
func TestKanbanBoard_CategoryPriority_CompletedTakesPrecedenceOverInProgress(t *testing.T) {
@@ -808,7 +809,7 @@ func TestKanbanBoard_ColumnMetadata(t *testing.T) {
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
require.NoError(t, err)
// Verify all 6 columns exist with correct metadata
// Verify all 5 visible columns exist with correct metadata
expectedColumns := []struct {
name string
displayName string
@@ -822,10 +823,9 @@ func TestKanbanBoard_ColumnMetadata(t *testing.T) {
{"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"},
{"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"},
}
assert.Len(t, board.Columns, 6, "Board should have 6 columns")
assert.Len(t, board.Columns, 5, "Board should have 5 visible columns")
for i, expected := range expectedColumns {
col := board.Columns[i]
@@ -861,26 +861,28 @@ func TestKanbanBoard_MultipleResidences(t *testing.T) {
board, err := repo.GetKanbanDataForMultipleResidences([]uint{residence1.ID, residence2.ID}, 30, time.Now().UTC())
require.NoError(t, err)
// Count total tasks
// Count total tasks (cancelled task is intentionally hidden from board)
totalTasks := 0
for _, col := range board.Columns {
totalTasks += col.Count
}
assert.Equal(t, 3, totalTasks, "Should have 3 tasks total across both residences")
assert.Equal(t, 2, totalTasks, "Should have 2 visible tasks total across both residences")
// Find upcoming and cancelled columns
var upcomingColumn, cancelledColumn *models.KanbanColumn
// Find upcoming column and ensure cancelled column is hidden
var upcomingColumn *models.KanbanColumn
foundCancelledColumn := false
for i := range board.Columns {
if board.Columns[i].Name == "upcoming_tasks" {
upcomingColumn = &board.Columns[i]
}
if board.Columns[i].Name == "cancelled_tasks" {
cancelledColumn = &board.Columns[i]
foundCancelledColumn = true
}
}
assert.False(t, foundCancelledColumn, "cancelled column must be hidden")
require.NotNil(t, upcomingColumn, "upcoming column should exist")
assert.Equal(t, 2, upcomingColumn.Count, "Should have 2 upcoming tasks")
assert.Equal(t, 1, cancelledColumn.Count, "Should have 1 cancelled task")
}
// === Single-Purpose Function Tests ===
@@ -1562,7 +1564,8 @@ func TestKanbanBoardMatchesSinglePurposeFunctions(t *testing.T) {
require.NoError(t, err)
// Compare counts
var boardOverdue, boardDueSoon, boardInProgress, boardUpcoming, boardCompleted, boardCancelled int
var boardOverdue, boardDueSoon, boardInProgress, boardUpcoming, boardCompleted int
foundCancelledColumn := false
for _, col := range board.Columns {
switch col.Name {
case "overdue_tasks":
@@ -1576,7 +1579,7 @@ func TestKanbanBoardMatchesSinglePurposeFunctions(t *testing.T) {
case "completed_tasks":
boardCompleted = col.Count
case "cancelled_tasks":
boardCancelled = col.Count
foundCancelledColumn = true
}
}
@@ -1585,7 +1588,8 @@ func TestKanbanBoardMatchesSinglePurposeFunctions(t *testing.T) {
assert.Equal(t, len(inProgress), boardInProgress, "In Progress count mismatch")
assert.Equal(t, len(upcoming), boardUpcoming, "Upcoming count mismatch")
assert.Equal(t, len(completed), boardCompleted, "Completed count mismatch")
assert.Equal(t, len(cancelled), boardCancelled, "Cancelled count mismatch")
assert.False(t, foundCancelledColumn, "cancelled column must be hidden")
assert.NotEmpty(t, cancelled, "single-purpose cancelled query should still return cancelled tasks")
}
// === Additional Timezone Tests ===
@@ -2093,4 +2097,3 @@ func TestConsistency_OverduePredicateVsScopeVsRepo(t *testing.T) {
}
assert.Equal(t, expectedCount, len(repoTasks), "Overdue task count mismatch")
}

View File

@@ -134,10 +134,12 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
// Initialize Apple auth service
appleAuthService := services.NewAppleAuthService(deps.Cache, cfg)
googleAuthService := services.NewGoogleAuthService(deps.Cache, cfg)
// Initialize handlers
authHandler := handlers.NewAuthHandler(authService, deps.EmailService, deps.Cache)
authHandler.SetAppleAuthService(appleAuthService)
authHandler.SetGoogleAuthService(googleAuthService)
userHandler := handlers.NewUserHandler(userService)
residenceHandler := handlers.NewResidenceHandler(residenceService, deps.PDFService, deps.EmailService)
taskHandler := handlers.NewTaskHandler(taskService, deps.StorageService)
@@ -243,6 +245,7 @@ func setupPublicAuthRoutes(api *echo.Group, authHandler *handlers.AuthHandler) {
auth.POST("/verify-reset-code/", authHandler.VerifyResetCode)
auth.POST("/reset-password/", authHandler.ResetPassword)
auth.POST("/apple-sign-in/", authHandler.AppleSignIn)
auth.POST("/google-sign-in/", authHandler.GoogleSignIn)
}
}