diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index bf8c4b4..f6a675a 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -1,6 +1,12 @@
{
+ "permissions": {
+ "allow": [
+ "WebSearch",
+ "WebFetch(domain:github.com)"
+ ]
+ },
+ "enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"ios-simulator"
- ],
- "enableAllProjectMcpServers": true
+ ]
}
diff --git a/admin/src/app/(dashboard)/automation-reference/page.tsx b/admin/src/app/(dashboard)/automation-reference/page.tsx
new file mode 100644
index 0000000..97d205a
--- /dev/null
+++ b/admin/src/app/(dashboard)/automation-reference/page.tsx
@@ -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 (
+
+
+
Automation Reference
+
+ Quick reference for all automated emails, notifications, and scheduled jobs
+
+
+
+ {/* Scheduled Jobs */}
+
+
+
+
+ Scheduled Jobs (Cron)
+
+
+ Background jobs that run on a schedule. All times are in UTC.
+
+
+
+
+
+
+ Job
+ Schedule
+ Default Hour
+ Description
+
+
+
+
+ Task Reminders (Due Soon)
+
+ 0 20 * * *
+ Daily 8 PM UTC
+
+
+ 20:00 (8 PM)
+
+
+
+ Sends frequency-aware "due soon" task reminders. Reminder schedule varies by task frequency
+ (weekly: day-of only, monthly: 7 days + day-of, annual: 30/14/7 days + day-of).
+
+
+
+
+ Overdue Reminders
+
+ 0 9 * * *
+ Daily 9 AM UTC
+
+
+ 09:00 (9 AM)
+
+
+
+ Sends overdue task reminders with tapering: daily for days 1-3, every 3 days for days 4-14,
+ then stops to avoid notification fatigue.
+
+
+
+
+ Daily Digest
+
+ 0 11 * * *
+ Daily 11 AM UTC
+
+
+ 11:00 (11 AM)
+
+
+
+ Summary push notification with overdue count and tasks due this week.
+ Uses user's stored timezone for accurate overdue calculation.
+
+
+
+
+ Onboarding Emails
+
+ Not scheduled
+ Manual only
+
+
+ -
+
+
+
+ Handler exists but not registered in scheduler. Can be triggered manually from admin.
+ Sends emails to users who haven't created residences (2+ days) or tasks (5+ days).
+
+
+
+
+
+
+
+
+ {/* Push Notification Types */}
+
+
+
+
+ Push Notification Types
+
+
+ Types of push notifications and when they are sent
+
+
+
+
+
+
+ Type
+ Trigger
+ User Preference
+ Custom Time Field
+
+
+
+
+
+ task_due_soon
+
+ Smart Reminder job (tasks due within 2 days)
+ task_due_soon
+ task_due_soon_hour
+
+
+
+ task_overdue
+
+ Smart Reminder job (past due date)
+ task_overdue
+ task_overdue_hour
+
+
+
+ task_completed
+
+ When another user completes a task
+ task_completed
+ -
+
+
+
+ task_assigned
+
+ When assigned to a task
+ task_assigned
+ -
+
+
+
+ residence_shared
+
+ When a residence is shared with user
+ residence_shared
+ -
+
+
+
+ warranty_expiring
+
+ Document warranty expiring (not yet implemented)
+ warranty_expiring
+ warranty_expiring_hour
+
+
+
+ daily_digest
+
+ Daily Digest job (summary notification)
+ daily_digest
+ daily_digest_hour
+
+
+
+
+
+
+ {/* Email Templates */}
+
+
+
+
+ Email Templates
+
+
+ All email templates and when they are sent
+
+
+
+
+
+
+
+ Transactional Emails
+
+
+
+ Onboarding Emails
+
+
+
+
+
+
+ Email
+ Subject
+ Trigger
+ Expiry
+
+
+
+
+ Welcome Email
+ Welcome to Casera - Verify Your Email
+ User registration (email/password)
+ 24 hours (verification code)
+
+
+ Apple Welcome
+ Welcome to Casera!
+ Apple Sign In registration
+ -
+
+
+ Google Welcome
+ Welcome to Casera!
+ Google Sign In registration
+ -
+
+
+ Post-Verification
+ You're All Set! Getting Started with Casera
+ After email verification
+ -
+
+
+ Verification Email
+ Casera - Verify Your Email
+ Resend verification code
+ 24 hours
+
+
+ Password Reset
+ Casera - Password Reset Request
+ Password reset request
+ 15 minutes
+
+
+ Password Changed
+ Casera - Your Password Has Been Changed
+ After password change
+ -
+
+
+ Task Completed
+ Casera - Task Completed: [Task Title]
+ When task is completed (if email_task_completed = true)
+ -
+
+
+ Tasks Report
+ Casera - Tasks Report for [Residence]
+ User requests PDF export
+ -
+
+
+
+
+
+
+ Cron-triggered daily at 10:00 AM UTC. Each email type sent only once per user.
+
+
+
+
+ Email Type
+ Subject
+ Criteria
+
+
+
+
+
+ no_residence
+
+ Get started with Casera - Add your first property
+ 2+ days since registration, no residence created
+
+
+
+ no_tasks
+
+ Stay on top of home maintenance with Casera
+ 5+ days since first residence, no tasks created
+
+
+
+
+
+
+
+
+ {/* Default Configuration */}
+
+
+
+
+ Scheduled Job Times
+
+
+ Hardcoded cron schedules in internal/worker/scheduler.go (all times UTC)
+
+
+
+
+
+
+ Cron Expression
+ Hour (UTC)
+ Description
+
+
+
+
+ Cron: 0 20 * * *
+
+ 20
+ (8:00 PM UTC)
+
+ Task reminders (due soon) - hardcoded in scheduler.go
+
+
+ Cron: 0 9 * * *
+
+ 9
+ (9:00 AM UTC)
+
+ Overdue reminders - hardcoded in scheduler.go
+
+
+ Cron: 0 11 * * *
+
+ 11
+ (11:00 AM UTC)
+
+ Daily digest - hardcoded in scheduler.go
+
+
+
+
+
+
+ {/* Smart Reminder Logic */}
+
+
+
+
+ Smart Reminder Logic
+
+
+ How the frequency-aware reminder system works
+
+
+
+
+
Reminder Stages by Frequency
+
+ Source: internal/notifications/reminder_config.go
+
+
+
+
Once / Daily / Weekly:
+
+
+
+
Bi-Weekly (14d):
+
+ - 1 day before
+ - Day-of
+
+
+
+
Monthly (30d):
+
+ - 3 days before
+ - 1 day before
+ - Day-of
+
+
+
+
Quarterly (90d):
+
+ - 7 days before
+ - 3 days before
+ - 1 day before
+ - Day-of
+
+
+
+
Semi-Annually (180d):
+
+ - 14 days before
+ - 7 days before
+ - 3 days before
+ - 1 day before
+ - Day-of
+
+
+
+
Annually (365d):
+
+ - 30 days before
+ - 14 days before
+ - 7 days before
+ - 3 days before
+ - 1 day before
+ - Day-of
+
+
+
+
+
+
+
Overdue Reminder Tapering
+
+ Overdue reminders taper off to avoid notification fatigue (configured in reminder_config.go):
+
+
+ - Days 1-3: Daily reminders (DailyReminderDays: 3)
+ - Days 4, 7, 10, 13: Every 3 days (TaperIntervalDays: 3)
+ - After 14 days: No more reminders (MaxOverdueDays: 14)
+
+
+
+
+
Duplicate Prevention
+
+ Each reminder is tracked in the task_reminder_logs 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.
+
+
+
+
+
+ {/* Timezone Handling */}
+
+
+
+
+ Timezone Handling
+
+
+ How user timezones are captured and used
+
+
+
+
+
Auto-Capture
+
+ The mobile app sends an X-Timezone header
+ on every API request (IANA format, e.g., "America/Los_Angeles").
+ The server captures this when the user fetches their tasks (happens on every app launch)
+ and stores it in the timezone column of
+ notifications_notificationpreference.
+
+
+
+
+
Usage in Daily Digest
+
+ The Daily Digest job uses the stored timezone to calculate "start of day" in the user's
+ local time. This ensures the overdue task count matches what the user sees in the app's Kanban view.
+ If no timezone is stored, UTC is used as fallback.
+
+
+
+
+
+ );
+}
diff --git a/admin/src/components/app-sidebar.tsx b/admin/src/components/app-sidebar.tsx
index 93a3e73..8a18497 100644
--- a/admin/src/components/app-sidebar.tsx
+++ b/admin/src/components/app-sidebar.tsx
@@ -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 },
diff --git a/docs/TASK_KANBAN_CATEGORIZATION.md b/docs/TASK_KANBAN_CATEGORIZATION.md
index 88cf6a2..3c2a918 100644
--- a/docs/TASK_KANBAN_CATEGORIZATION.md
+++ b/docs/TASK_KANBAN_CATEGORIZATION.md
@@ -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.
diff --git a/docs/TASK_KANBAN_LOGIC.md b/docs/TASK_KANBAN_LOGIC.md
index 39d4ac1..ac670d3 100644
--- a/docs/TASK_KANBAN_LOGIC.md
+++ b/docs/TASK_KANBAN_LOGIC.md
@@ -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
diff --git a/internal/handlers/task_handler_test.go b/internal/handlers/task_handler_test.go
index 76678d2..77523d8 100644
--- a/internal/handlers/task_handler_test.go
+++ b/internal/handlers/task_handler_test.go
@@ -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) {
diff --git a/internal/i18n/translations/en.json b/internal/i18n/translations/en.json
index 65d6f08..5a5d59b 100644
--- a/internal/i18n/translations/en.json
+++ b/internal/i18n/translations/en.json
@@ -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",
diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go
index 162f57c..6f306a0 100644
--- a/internal/integration/integration_test.go
+++ b/internal/integration/integration_test.go
@@ -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":
diff --git a/internal/repositories/task_repo.go b/internal/repositories/task_repo.go
index 769b72e..6248026 100644
--- a/internal/repositories/task_repo.go
+++ b/internal/repositories/task_repo.go
@@ -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)
diff --git a/internal/repositories/task_repo_test.go b/internal/repositories/task_repo_test.go
index 3e4b3ff..b76e6a5 100644
--- a/internal/repositories/task_repo_test.go
+++ b/internal/repositories/task_repo_test.go
@@ -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")
}
-
diff --git a/internal/router/router.go b/internal/router/router.go
index 567bb0d..679f828 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -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)
}
}