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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user