Migrate from Gin to Echo framework and add comprehensive integration tests

Major changes:
- Migrate all handlers from Gin to Echo framework
- Add new apperrors, echohelpers, and validator packages
- Update middleware for Echo compatibility
- Add ArchivedHandler to task categorization chain (archived tasks go to cancelled_tasks column)
- Add 6 new integration tests:
  - RecurringTaskLifecycle: NextDueDate advancement for weekly/monthly tasks
  - MultiUserSharing: Complex sharing with user removal
  - TaskStateTransitions: All state transitions and kanban column changes
  - DateBoundaryEdgeCases: Threshold boundary testing
  - CascadeOperations: Residence deletion cascade effects
  - MultiUserOperations: Shared residence collaboration
- Add single-purpose repository functions for kanban columns (GetOverdueTasks, GetDueSoonTasks, etc.)
- Fix RemoveUser route param mismatch (userId -> user_id)
- Fix determineExpectedColumn helper to correctly prioritize in_progress over overdue

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-16 13:52:08 -06:00
parent c51f1ce34a
commit 6dac34e373
98 changed files with 8209 additions and 4425 deletions

View File

@@ -1,6 +1,7 @@
package repositories
import (
"fmt"
"time"
"gorm.io/gorm"
@@ -20,6 +21,193 @@ func NewTaskRepository(db *gorm.DB) *TaskRepository {
return &TaskRepository{db: db}
}
// === Task Filter Options ===
// TaskFilterOptions provides flexible filtering for task queries.
// Use exactly one of ResidenceID, ResidenceIDs, or UserIDs to specify the filter scope.
type TaskFilterOptions struct {
// Filter by single residence (kanban single-residence view)
ResidenceID uint
// Filter by multiple residences (kanban all-residences view)
ResidenceIDs []uint
// Filter by users - matches tasks where assigned_to IN userIDs
// OR residence owner IN userIDs (for notifications)
UserIDs []uint
// Include archived tasks (default: false, excludes archived)
IncludeArchived bool
// IncludeInProgress controls whether in-progress tasks are included in
// overdue/due-soon/upcoming queries. Default is false (excludes in-progress)
// for kanban column consistency. Set to true for notifications where
// users should still be notified about in-progress tasks that are overdue.
IncludeInProgress bool
// Preload options
PreloadCreatedBy bool
PreloadAssignedTo bool
PreloadResidence bool
PreloadCompletions bool // Minimal: just id, task_id, completed_at
}
// applyFilterOptions applies the filter options to a query.
// Returns a new query with filters and preloads applied.
func (r *TaskRepository) applyFilterOptions(query *gorm.DB, opts TaskFilterOptions) *gorm.DB {
// Apply residence/user filters
if opts.ResidenceID != 0 {
query = query.Where("task_task.residence_id = ?", opts.ResidenceID)
} else if len(opts.ResidenceIDs) > 0 {
query = query.Where("task_task.residence_id IN ?", opts.ResidenceIDs)
} else if len(opts.UserIDs) > 0 {
// For notifications: tasks assigned to users OR owned by users
query = query.Where(
"(task_task.assigned_to_id IN ? OR task_task.residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ?))",
opts.UserIDs, opts.UserIDs,
)
}
// Apply archived filter (default excludes archived)
if !opts.IncludeArchived {
query = query.Where("task_task.is_archived = ?", false)
}
// Apply preloads
if opts.PreloadCreatedBy {
query = query.Preload("CreatedBy")
}
if opts.PreloadAssignedTo {
query = query.Preload("AssignedTo")
}
if opts.PreloadResidence {
query = query.Preload("Residence")
}
if opts.PreloadCompletions {
query = query.Preload("Completions", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "task_id", "completed_at")
})
}
return query
}
// === Single-Purpose Task Query Functions ===
// These functions use the scopes from internal/task/scopes for consistent filtering.
// They are the single source of truth for task categorization queries, used by both
// kanban and notification handlers.
// GetOverdueTasks returns active, non-completed tasks past their effective due date.
// Uses task.ScopeOverdue for consistent filtering logic.
// The `now` parameter should be in the user's timezone for accurate overdue detection.
//
// By default, excludes in-progress tasks for kanban column consistency.
// Set opts.IncludeInProgress=true for notifications where in-progress tasks should still appear.
func (r *TaskRepository) GetOverdueTasks(now time.Time, opts TaskFilterOptions) ([]models.Task, error) {
var tasks []models.Task
query := r.db.Model(&models.Task{})
if opts.IncludeArchived {
// When including archived, build the query manually to skip the archived check
// but still apply cancelled check, not-completed check, and date check
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
query = query.Where("is_cancelled = ?", false).
Scopes(task.ScopeNotCompleted).
Where("COALESCE(next_due_date, due_date) < ?", startOfDay)
} else {
// Use the combined scope which includes is_archived = false
query = query.Scopes(task.ScopeOverdue(now))
}
query = query.Scopes(task.ScopeKanbanOrder)
if !opts.IncludeInProgress {
query = query.Scopes(task.ScopeNotInProgress)
}
query = r.applyFilterOptions(query, opts)
err := query.Find(&tasks).Error
return tasks, err
}
// GetDueSoonTasks returns active, non-completed tasks due within the threshold.
// Uses task.ScopeDueSoon for consistent filtering logic.
// The `now` parameter should be in the user's timezone for accurate detection.
//
// By default, excludes in-progress tasks for kanban column consistency.
// Set opts.IncludeInProgress=true for notifications where in-progress tasks should still appear.
func (r *TaskRepository) GetDueSoonTasks(now time.Time, daysThreshold int, opts TaskFilterOptions) ([]models.Task, error) {
var tasks []models.Task
query := r.db.Model(&models.Task{}).
Scopes(task.ScopeDueSoon(now, daysThreshold), task.ScopeKanbanOrder)
if !opts.IncludeInProgress {
query = query.Scopes(task.ScopeNotInProgress)
}
query = r.applyFilterOptions(query, opts)
err := query.Find(&tasks).Error
return tasks, err
}
// GetInProgressTasks returns active, non-completed tasks marked as in-progress.
// Uses task.ScopeInProgress for consistent filtering logic.
//
// Note: Excludes completed tasks to match kanban column behavior (completed has higher priority).
func (r *TaskRepository) GetInProgressTasks(opts TaskFilterOptions) ([]models.Task, error) {
var tasks []models.Task
query := r.db.Model(&models.Task{}).
Scopes(task.ScopeActive, task.ScopeNotCompleted, task.ScopeInProgress, task.ScopeKanbanOrder)
query = r.applyFilterOptions(query, opts)
err := query.Find(&tasks).Error
return tasks, err
}
// GetUpcomingTasks returns active, non-completed tasks due after the threshold or with no due date.
// Uses task.ScopeUpcoming for consistent filtering logic.
//
// By default, excludes in-progress tasks for kanban column consistency.
// Set opts.IncludeInProgress=true for notifications where in-progress tasks should still appear.
func (r *TaskRepository) GetUpcomingTasks(now time.Time, daysThreshold int, opts TaskFilterOptions) ([]models.Task, error) {
var tasks []models.Task
query := r.db.Model(&models.Task{}).
Scopes(task.ScopeUpcoming(now, daysThreshold), task.ScopeKanbanOrder)
if !opts.IncludeInProgress {
query = query.Scopes(task.ScopeNotInProgress)
}
query = r.applyFilterOptions(query, opts)
err := query.Find(&tasks).Error
return tasks, err
}
// GetCompletedTasks returns completed tasks (NextDueDate nil with at least one completion).
// Uses task.ScopeCompleted for consistent filtering logic.
func (r *TaskRepository) GetCompletedTasks(opts TaskFilterOptions) ([]models.Task, error) {
var tasks []models.Task
// Completed tasks: not cancelled, has completion, no next due date
// Note: We don't apply ScopeActive because completed tasks may not be "active" in that sense
query := r.db.Model(&models.Task{}).
Where("is_cancelled = ?", false).
Scopes(task.ScopeCompleted, task.ScopeKanbanOrder)
query = r.applyFilterOptions(query, opts)
err := query.Find(&tasks).Error
return tasks, err
}
// GetCancelledTasks returns cancelled OR archived tasks.
// Archived tasks are grouped with cancelled for kanban purposes - they both represent
// tasks that are no longer active/actionable.
func (r *TaskRepository) GetCancelledTasks(opts TaskFilterOptions) ([]models.Task, error) {
var tasks []models.Task
// Include both cancelled and archived tasks in this column
// Archived tasks should ONLY appear here, not in any other column
query := r.db.Model(&models.Task{}).
Where("is_cancelled = ? OR is_archived = ?", true, true).
Scopes(task.ScopeKanbanOrder)
// Override IncludeArchived to true since this function specifically handles archived tasks
opts.IncludeArchived = true
query = r.applyFilterOptions(query, opts)
err := query.Find(&tasks).Error
return tasks, err
}
// === Task CRUD ===
// FindByID finds a task by ID with preloaded relations
@@ -125,180 +313,175 @@ func (r *TaskRepository) Unarchive(id uint) error {
// === Kanban Board ===
// buildKanbanColumns builds the kanban column array from categorized task slices.
// This is a helper function to reduce duplication between GetKanbanData and GetKanbanDataForMultipleResidences.
func buildKanbanColumns(
overdue, inProgress, dueSoon, upcoming, completed, cancelled []models.Task,
) []models.KanbanColumn {
return []models.KanbanColumn{
{
Name: string(categorization.ColumnOverdue),
DisplayName: "Overdue",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"},
Color: "#FF3B30",
Tasks: overdue,
Count: len(overdue),
},
{
Name: string(categorization.ColumnInProgress),
DisplayName: "In Progress",
ButtonTypes: []string{"edit", "complete", "cancel"},
Icons: map[string]string{"ios": "hammer", "android": "Build"},
Color: "#5856D6",
Tasks: inProgress,
Count: len(inProgress),
},
{
Name: string(categorization.ColumnDueSoon),
DisplayName: "Due Soon",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "clock", "android": "Schedule"},
Color: "#FF9500",
Tasks: dueSoon,
Count: len(dueSoon),
},
{
Name: string(categorization.ColumnUpcoming),
DisplayName: "Upcoming",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "calendar", "android": "Event"},
Color: "#007AFF",
Tasks: upcoming,
Count: len(upcoming),
},
{
Name: string(categorization.ColumnCompleted),
DisplayName: "Completed",
ButtonTypes: []string{},
Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"},
Color: "#34C759",
Tasks: completed,
Count: len(completed),
},
{
Name: string(categorization.ColumnCancelled),
DisplayName: "Cancelled",
ButtonTypes: []string{"uncancel", "delete"},
Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"},
Color: "#8E8E93",
Tasks: cancelled,
Count: len(cancelled),
},
}
}
// GetKanbanData retrieves tasks organized for kanban display.
// Uses the task.categorization package as the single source of truth for categorization logic.
// Uses single-purpose query functions for each column type, ensuring consistency
// with notification handlers that use the same functions.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
//
// Optimization: Preloads only minimal completion data (id, task_id, completed_at) for count/detection.
// Images and CompletedBy are NOT preloaded - fetch separately when viewing completion details.
func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) {
var tasks []models.Task
// Note: Category, Priority, Frequency are NOT preloaded - client resolves from cache using IDs
// Optimization: Preload only minimal Completions data (no Images, no CompletedBy)
err := r.db.Preload("CreatedBy").
Preload("AssignedTo").
Preload("Completions", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "task_id", "completed_at")
}).
Where("residence_id = ? AND is_archived = ?", residenceID, false).
Scopes(task.ScopeKanbanOrder).
Find(&tasks).Error
opts := TaskFilterOptions{
ResidenceID: residenceID,
PreloadCreatedBy: true,
PreloadAssignedTo: true,
PreloadCompletions: true,
}
// Query each column using single-purpose functions
// These functions use the same scopes as notification handlers for consistency
overdue, err := r.GetOverdueTasks(now, opts)
if err != nil {
return nil, err
return nil, fmt.Errorf("get overdue tasks: %w", err)
}
// Use the categorization package as the single source of truth
// Pass the user's timezone-aware time for accurate overdue detection
categorized := categorization.CategorizeTasksIntoColumnsWithTime(tasks, daysThreshold, now)
columns := []models.KanbanColumn{
{
Name: string(categorization.ColumnOverdue),
DisplayName: "Overdue",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"},
Color: "#FF3B30",
Tasks: categorized[categorization.ColumnOverdue],
Count: len(categorized[categorization.ColumnOverdue]),
},
{
Name: string(categorization.ColumnInProgress),
DisplayName: "In Progress",
ButtonTypes: []string{"edit", "complete", "cancel"},
Icons: map[string]string{"ios": "hammer", "android": "Build"},
Color: "#5856D6",
Tasks: categorized[categorization.ColumnInProgress],
Count: len(categorized[categorization.ColumnInProgress]),
},
{
Name: string(categorization.ColumnDueSoon),
DisplayName: "Due Soon",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "clock", "android": "Schedule"},
Color: "#FF9500",
Tasks: categorized[categorization.ColumnDueSoon],
Count: len(categorized[categorization.ColumnDueSoon]),
},
{
Name: string(categorization.ColumnUpcoming),
DisplayName: "Upcoming",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "calendar", "android": "Event"},
Color: "#007AFF",
Tasks: categorized[categorization.ColumnUpcoming],
Count: len(categorized[categorization.ColumnUpcoming]),
},
{
Name: string(categorization.ColumnCompleted),
DisplayName: "Completed",
ButtonTypes: []string{},
Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"},
Color: "#34C759",
Tasks: categorized[categorization.ColumnCompleted],
Count: len(categorized[categorization.ColumnCompleted]),
},
{
Name: string(categorization.ColumnCancelled),
DisplayName: "Cancelled",
ButtonTypes: []string{"uncancel", "delete"},
Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"},
Color: "#8E8E93",
Tasks: categorized[categorization.ColumnCancelled],
Count: len(categorized[categorization.ColumnCancelled]),
},
inProgress, err := r.GetInProgressTasks(opts)
if err != nil {
return nil, fmt.Errorf("get in-progress tasks: %w", err)
}
dueSoon, err := r.GetDueSoonTasks(now, daysThreshold, opts)
if err != nil {
return nil, fmt.Errorf("get due-soon tasks: %w", err)
}
upcoming, err := r.GetUpcomingTasks(now, daysThreshold, opts)
if err != nil {
return nil, fmt.Errorf("get upcoming tasks: %w", err)
}
completed, err := r.GetCompletedTasks(opts)
if err != nil {
return nil, fmt.Errorf("get completed tasks: %w", err)
}
cancelled, err := r.GetCancelledTasks(opts)
if err != nil {
return nil, fmt.Errorf("get cancelled tasks: %w", err)
}
columns := buildKanbanColumns(overdue, inProgress, dueSoon, upcoming, completed, cancelled)
return &models.KanbanBoard{
Columns: columns,
DaysThreshold: daysThreshold,
ResidenceID: string(rune(residenceID)),
ResidenceID: fmt.Sprintf("%d", residenceID),
}, nil
}
// GetKanbanDataForMultipleResidences retrieves tasks from multiple residences organized for kanban display.
// Uses the task.categorization package as the single source of truth for categorization logic.
// Uses single-purpose query functions for each column type, ensuring consistency
// with notification handlers that use the same functions.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
//
// Optimization: Preloads only minimal completion data (id, task_id, completed_at) for count/detection.
// Images and CompletedBy are NOT preloaded - fetch separately when viewing completion details.
func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) {
var tasks []models.Task
// Note: Category, Priority, Frequency are NOT preloaded - client resolves from cache using IDs
// Optimization: Preload only minimal Completions data (no Images, no CompletedBy)
err := r.db.Preload("CreatedBy").
Preload("AssignedTo").
Preload("Residence").
Preload("Completions", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "task_id", "completed_at")
}).
Where("residence_id IN ? AND is_archived = ?", residenceIDs, false).
Scopes(task.ScopeKanbanOrder).
Find(&tasks).Error
opts := TaskFilterOptions{
ResidenceIDs: residenceIDs,
PreloadCreatedBy: true,
PreloadAssignedTo: true,
PreloadResidence: true,
PreloadCompletions: true,
}
// Query each column using single-purpose functions
// These functions use the same scopes as notification handlers for consistency
overdue, err := r.GetOverdueTasks(now, opts)
if err != nil {
return nil, err
return nil, fmt.Errorf("get overdue tasks: %w", err)
}
// Use the categorization package as the single source of truth
// Pass the user's timezone-aware time for accurate overdue detection
categorized := categorization.CategorizeTasksIntoColumnsWithTime(tasks, daysThreshold, now)
columns := []models.KanbanColumn{
{
Name: string(categorization.ColumnOverdue),
DisplayName: "Overdue",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"},
Color: "#FF3B30",
Tasks: categorized[categorization.ColumnOverdue],
Count: len(categorized[categorization.ColumnOverdue]),
},
{
Name: string(categorization.ColumnInProgress),
DisplayName: "In Progress",
ButtonTypes: []string{"edit", "complete", "cancel"},
Icons: map[string]string{"ios": "hammer", "android": "Build"},
Color: "#5856D6",
Tasks: categorized[categorization.ColumnInProgress],
Count: len(categorized[categorization.ColumnInProgress]),
},
{
Name: string(categorization.ColumnDueSoon),
DisplayName: "Due Soon",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "clock", "android": "Schedule"},
Color: "#FF9500",
Tasks: categorized[categorization.ColumnDueSoon],
Count: len(categorized[categorization.ColumnDueSoon]),
},
{
Name: string(categorization.ColumnUpcoming),
DisplayName: "Upcoming",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "calendar", "android": "Event"},
Color: "#007AFF",
Tasks: categorized[categorization.ColumnUpcoming],
Count: len(categorized[categorization.ColumnUpcoming]),
},
{
Name: string(categorization.ColumnCompleted),
DisplayName: "Completed",
ButtonTypes: []string{},
Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"},
Color: "#34C759",
Tasks: categorized[categorization.ColumnCompleted],
Count: len(categorized[categorization.ColumnCompleted]),
},
{
Name: string(categorization.ColumnCancelled),
DisplayName: "Cancelled",
ButtonTypes: []string{"uncancel", "delete"},
Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"},
Color: "#8E8E93",
Tasks: categorized[categorization.ColumnCancelled],
Count: len(categorized[categorization.ColumnCancelled]),
},
inProgress, err := r.GetInProgressTasks(opts)
if err != nil {
return nil, fmt.Errorf("get in-progress tasks: %w", err)
}
dueSoon, err := r.GetDueSoonTasks(now, daysThreshold, opts)
if err != nil {
return nil, fmt.Errorf("get due-soon tasks: %w", err)
}
upcoming, err := r.GetUpcomingTasks(now, daysThreshold, opts)
if err != nil {
return nil, fmt.Errorf("get upcoming tasks: %w", err)
}
completed, err := r.GetCompletedTasks(opts)
if err != nil {
return nil, fmt.Errorf("get completed tasks: %w", err)
}
cancelled, err := r.GetCancelledTasks(opts)
if err != nil {
return nil, fmt.Errorf("get cancelled tasks: %w", err)
}
columns := buildKanbanColumns(overdue, inProgress, dueSoon, upcoming, completed, cancelled)
return &models.KanbanBoard{
Columns: columns,
DaysThreshold: daysThreshold,
@@ -419,83 +602,6 @@ func (r *TaskRepository) FindCompletionImageByID(id uint) (*models.TaskCompletio
return &image, nil
}
// TaskStatistics represents aggregated task statistics
type TaskStatistics struct {
TotalTasks int
TotalPending int
TotalOverdue int
TasksDueNextWeek int
TasksDueNextMonth int
}
// GetTaskStatistics returns aggregated task statistics for multiple residences.
// Uses a single optimized query with CASE statements instead of 5 separate queries.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
func (r *TaskRepository) GetTaskStatistics(residenceIDs []uint, now time.Time) (*TaskStatistics, error) {
if len(residenceIDs) == 0 {
return &TaskStatistics{}, nil
}
nextWeek := now.AddDate(0, 0, 7)
nextMonth := now.AddDate(0, 0, 30)
// Single query with CASE statements to count all statistics at once
// This replaces 5 separate COUNT queries with 1 query
type statsResult struct {
TotalTasks int64
TotalOverdue int64
TotalPending int64
TasksDueNextWeek int64
TasksDueNextMonth int64
}
var result statsResult
// Build the optimized query
// Base conditions: active (not cancelled, not archived), in specified residences
// NotCompleted: NOT (next_due_date IS NULL AND has completions)
err := r.db.Model(&models.Task{}).
Select(`
COUNT(*) as total_tasks,
COUNT(CASE
WHEN COALESCE(next_due_date, due_date)::timestamp < ?::timestamp
AND NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))
THEN 1
END) as total_overdue,
COUNT(CASE
WHEN NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))
THEN 1
END) as total_pending,
COUNT(CASE
WHEN COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp
AND COALESCE(next_due_date, due_date)::timestamp < ?::timestamp
AND NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))
THEN 1
END) as tasks_due_next_week,
COUNT(CASE
WHEN COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp
AND COALESCE(next_due_date, due_date)::timestamp < ?::timestamp
AND NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))
THEN 1
END) as tasks_due_next_month
`, now, now, nextWeek, now, nextMonth).
Where("residence_id IN ?", residenceIDs).
Where("is_cancelled = ? AND is_archived = ?", false, false).
Scan(&result).Error
if err != nil {
return nil, err
}
return &TaskStatistics{
TotalTasks: int(result.TotalTasks),
TotalPending: int(result.TotalPending),
TotalOverdue: int(result.TotalOverdue),
TasksDueNextWeek: int(result.TasksDueNextWeek),
TasksDueNextMonth: int(result.TasksDueNextMonth),
}, nil
}
// GetOverdueCountByResidence returns a map of residence ID to overdue task count.
// Uses the task.scopes package for consistent filtering logic.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.