Total rebrand across all Go API source files: - Go module path: casera-api -> honeydue-api - All imports updated (130+ files) - Docker: containers, images, networks renamed - Email templates: support email, noreply, icon URL - Domains: casera.app/mycrib.treytartt.com -> honeyDue.treytartt.com - Bundle IDs: com.tt.casera -> com.tt.honeyDue - IAP product IDs updated - Landing page, admin panel, config defaults - Seeds, CI workflows, Makefile, docs - Database table names preserved (no migration needed) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
217 lines
7.6 KiB
Go
217 lines
7.6 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/honeydue-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 before today.
|
|
//
|
|
// A task is overdue when:
|
|
// - It has an effective date (NextDueDate or DueDate)
|
|
// - That date is before the start of the current day
|
|
// - The task is not completed, cancelled, or archived
|
|
//
|
|
// Note: A task due "today" is NOT overdue. It becomes overdue tomorrow.
|
|
//
|
|
// SQL equivalent (in scopes.go ScopeOverdue):
|
|
//
|
|
// COALESCE(next_due_date, due_date) < DATE_TRUNC('day', ?)
|
|
// 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
|
|
}
|
|
// Compare against start of today, not current time
|
|
// A task due "today" should not be overdue until tomorrow
|
|
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
return effectiveDate.Before(startOfDay)
|
|
}
|
|
|
|
// 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 >= start of today AND < start of (today + daysThreshold)
|
|
// - The task is not completed, cancelled, archived, or already overdue
|
|
//
|
|
// Note: Uses start of day for comparisons so tasks due "today" are included.
|
|
//
|
|
// SQL equivalent (in scopes.go ScopeDueSoon):
|
|
//
|
|
// COALESCE(next_due_date, due_date) >= DATE_TRUNC('day', ?)
|
|
// AND COALESCE(next_due_date, due_date) < DATE_TRUNC('day', ?) + interval 'N days'
|
|
// 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
|
|
}
|
|
// Use start of day for comparisons
|
|
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
threshold := startOfDay.AddDate(0, 0, daysThreshold)
|
|
// Due soon = not overdue (>= start of today) AND before threshold
|
|
return !effectiveDate.Before(startOfDay) && 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 >= start of (today + daysThreshold)
|
|
// - The task is not completed, cancelled, or archived
|
|
//
|
|
// Note: Uses start of day for comparisons for consistency with other predicates.
|
|
//
|
|
// 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
|
|
}
|
|
// Use start of day for comparisons
|
|
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
threshold := startOfDay.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)
|
|
}
|