Files
honeyDueAPI/internal/task/predicates/predicates.go
Trey t 0cf64cfb0c Add performance optimizations and database indexes
Database Indexes (migrations 006-009):
- Add case-insensitive indexes for auth lookups (email, username)
- Add composite indexes for task kanban queries
- Add indexes for notification, document, and completion queries
- Add unique index for active share codes
- Remove redundant idx_share_code_active and idx_notification_user_sent

Repository Optimizations:
- Add FindResidenceIDsByUser() lightweight method (IDs only, no preloads)
- Optimize GetResidenceUsers() with single UNION query (was 2 queries)
- Optimize kanban completion preloads to minimal columns (id, task_id, completed_at)

Service Optimizations:
- Remove Category/Priority/Frequency preloads from task queries
- Remove summary calculations from CRUD responses (client calculates)
- Use lightweight FindResidenceIDsByUser() instead of full FindByUser()

These changes reduce database load and response times for common operations.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 01:06:08 -06:00

204 lines
6.7 KiB
Go

// Package predicates provides pure predicate functions for task logic.
// These functions are the SINGLE SOURCE OF TRUTH for all task-related business logic.
//
// IMPORTANT: The scopes in ../scopes/scopes.go must mirror these predicates exactly.
// Any change to predicate logic MUST be reflected in the corresponding scope.
// Tests verify consistency between predicates and scopes.
package predicates
import (
"time"
"github.com/treytartt/casera-api/internal/models"
)
// =============================================================================
// STATE PREDICATES
// =============================================================================
// IsCompleted returns true if a task is considered "completed" per kanban rules.
//
// A task is completed when:
// - NextDueDate is nil (no future occurrence scheduled)
// - AND it has at least one completion record
//
// This applies to one-time tasks. Recurring tasks always have a NextDueDate
// after completion, so they never enter the "completed" state permanently.
//
// SQL equivalent (in scopes.go ScopeCompleted):
//
// next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)
func IsCompleted(task *models.Task) bool {
return task.NextDueDate == nil && HasCompletions(task)
}
// IsActive returns true if the task is not cancelled and not archived.
// Active tasks are eligible for display in the kanban board.
//
// SQL equivalent (in scopes.go ScopeActive):
//
// is_cancelled = false AND is_archived = false
func IsActive(task *models.Task) bool {
return !task.IsCancelled && !task.IsArchived
}
// IsCancelled returns true if the task has been cancelled.
//
// SQL equivalent:
//
// is_cancelled = true
func IsCancelled(task *models.Task) bool {
return task.IsCancelled
}
// IsArchived returns true if the task has been archived.
//
// SQL equivalent:
//
// is_archived = true
func IsArchived(task *models.Task) bool {
return task.IsArchived
}
// IsInProgress returns true if the task is marked as in progress.
//
// SQL equivalent (in scopes.go ScopeInProgress):
//
// in_progress = true
func IsInProgress(task *models.Task) bool {
return task.InProgress
}
// =============================================================================
// DATE PREDICATES
// =============================================================================
// EffectiveDate returns the date used for scheduling calculations.
//
// For recurring tasks that have been completed at least once, NextDueDate
// contains the next occurrence. For new tasks or one-time tasks, we fall
// back to DueDate.
//
// Returns nil if task has no due date set.
//
// SQL equivalent:
//
// COALESCE(next_due_date, due_date)
func EffectiveDate(task *models.Task) *time.Time {
if task.NextDueDate != nil {
return task.NextDueDate
}
return task.DueDate
}
// IsOverdue returns true if the task's effective date is in the past.
//
// A task is overdue when:
// - It has an effective date (NextDueDate or DueDate)
// - That date is before the given time
// - The task is not completed, cancelled, or archived
//
// SQL equivalent (in scopes.go ScopeOverdue):
//
// COALESCE(next_due_date, due_date) < ?
// AND NOT (next_due_date IS NULL AND EXISTS completion)
// AND is_cancelled = false AND is_archived = false
func IsOverdue(task *models.Task, now time.Time) bool {
if !IsActive(task) || IsCompleted(task) {
return false
}
effectiveDate := EffectiveDate(task)
if effectiveDate == nil {
return false
}
return effectiveDate.Before(now)
}
// IsDueSoon returns true if the task's effective date is within the threshold.
//
// A task is "due soon" when:
// - It has an effective date (NextDueDate or DueDate)
// - That date is >= now AND < (now + daysThreshold)
// - The task is not completed, cancelled, archived, or already overdue
//
// SQL equivalent (in scopes.go ScopeDueSoon):
//
// COALESCE(next_due_date, due_date) >= ?
// AND COALESCE(next_due_date, due_date) < ?
// AND NOT (next_due_date IS NULL AND EXISTS completion)
// AND is_cancelled = false AND is_archived = false
func IsDueSoon(task *models.Task, now time.Time, daysThreshold int) bool {
if !IsActive(task) || IsCompleted(task) {
return false
}
effectiveDate := EffectiveDate(task)
if effectiveDate == nil {
return false
}
threshold := now.AddDate(0, 0, daysThreshold)
// Due soon = not overdue AND before threshold
return !effectiveDate.Before(now) && effectiveDate.Before(threshold)
}
// IsUpcoming returns true if the task is due after the threshold or has no due date.
//
// A task is "upcoming" when:
// - It has no effective date, OR
// - Its effective date is >= (now + daysThreshold)
// - The task is not completed, cancelled, or archived
//
// This is the default category for tasks that don't match other criteria.
func IsUpcoming(task *models.Task, now time.Time, daysThreshold int) bool {
if !IsActive(task) || IsCompleted(task) {
return false
}
effectiveDate := EffectiveDate(task)
if effectiveDate == nil {
return true // No due date = upcoming
}
threshold := now.AddDate(0, 0, daysThreshold)
return !effectiveDate.Before(threshold)
}
// =============================================================================
// COMPLETION HELPERS
// =============================================================================
// HasCompletions returns true if the task has at least one completion record.
// Supports both preloaded Completions slice and computed CompletionCount field
// for optimized queries that use COUNT subqueries instead of preloading.
func HasCompletions(task *models.Task) bool {
// If Completions were preloaded, use the slice
if len(task.Completions) > 0 {
return true
}
// Otherwise check the computed count (populated via subquery for optimized queries)
return task.CompletionCount > 0
}
// GetCompletionCount returns the number of completions for a task.
// Supports both preloaded Completions slice and computed CompletionCount field
// for optimized queries that use COUNT subqueries instead of preloading.
func GetCompletionCount(task *models.Task) int {
// If Completions were preloaded, use the slice length
if len(task.Completions) > 0 {
return len(task.Completions)
}
// Otherwise return the computed count (populated via subquery for optimized queries)
return task.CompletionCount
}
// =============================================================================
// RECURRING TASK HELPERS
// =============================================================================
// IsRecurring returns true if the task has a recurring frequency.
func IsRecurring(task *models.Task) bool {
return task.Frequency != nil && task.Frequency.Days != nil && *task.Frequency.Days > 0
}
// IsOneTime returns true if the task is a one-time (non-recurring) task.
func IsOneTime(task *models.Task) bool {
return !IsRecurring(task)
}