Files
honeyDueAPI/docs/TASK_LOGIC_ARCHITECTURE.md
Trey t 4976eafc6c Rebrand from Casera/MyCrib to honeyDue
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>
2026-03-07 06:33:38 -06:00

12 KiB

Task Logic Architecture

This document describes the consolidated task logic architecture following DRY principles. All task-related logic (completion detection, overdue calculation, kanban categorization, statistics counting) is centralized in the internal/task/ package.

Architecture Overview

internal/task/
├── predicates/
│   └── predicates.go     # SINGLE SOURCE OF TRUTH - Pure Go predicate functions
├── scopes/
│   └── scopes.go         # SQL mirrors of predicates for GORM queries
├── categorization/
│   └── chain.go          # Chain of Responsibility for kanban column determination
└── task.go               # Re-exports for convenient single import

Package Responsibilities

predicates/ - Single Source of Truth

Pure Go functions that define task logic. These are the canonical definitions for all task states.

import "github.com/treytartt/honeydue-api/internal/task/predicates"

// State checks
predicates.IsCompleted(task)      // NextDueDate == nil && len(Completions) > 0
predicates.IsActive(task)         // !IsCancelled && !IsArchived
predicates.IsCancelled(task)      // IsCancelled == true
predicates.IsArchived(task)       // IsArchived == true
predicates.IsInProgress(task)     // InProgress == true

// Date calculations
predicates.EffectiveDate(task)    // NextDueDate ?? DueDate
predicates.IsOverdue(task, now)   // Active, not completed, effectiveDate < now
predicates.IsDueSoon(task, now, days) // Active, not completed, now <= effectiveDate < threshold
predicates.IsUpcoming(task, now, days) // Everything else

scopes/ - SQL Mirrors for Database Queries

GORM scope functions that produce the same results as predicates, but execute at the database level. Use these when counting or filtering large datasets without loading all records into memory.

import "github.com/treytartt/honeydue-api/internal/task/scopes"

// State scopes
db.Scopes(scopes.ScopeActive)       // is_cancelled = false AND is_archived = false
db.Scopes(scopes.ScopeCompleted)    // next_due_date IS NULL AND EXISTS(completion)
db.Scopes(scopes.ScopeNotCompleted) // NOT (next_due_date IS NULL AND EXISTS(completion))
db.Scopes(scopes.ScopeInProgress)   // JOIN status WHERE name = 'In Progress'

// Date scopes (require time parameter)
db.Scopes(scopes.ScopeOverdue(now))           // COALESCE(next_due_date, due_date) < now
db.Scopes(scopes.ScopeDueSoon(now, 30))       // >= now AND < threshold
db.Scopes(scopes.ScopeUpcoming(now, 30))      // >= threshold OR no due date

// Filter scopes
db.Scopes(scopes.ScopeForResidence(id))       // residence_id = ?
db.Scopes(scopes.ScopeForResidences(ids))     // residence_id IN (?)

// Ordering
db.Scopes(scopes.ScopeKanbanOrder)            // Due date ASC, priority DESC, created DESC

categorization/ - Chain of Responsibility Pattern

Determines which kanban column a task belongs to. Uses predicates internally.

import "github.com/treytartt/honeydue-api/internal/task/categorization"

// Single task
column := categorization.CategorizeTask(task, 30)
columnStr := categorization.DetermineKanbanColumn(task, 30)

// Multiple tasks
columns := categorization.CategorizeTasksIntoColumns(tasks, 30)
// Returns map[KanbanColumn][]Task

task.go - Convenient Re-exports

For most use cases, import the main task package which re-exports everything:

import "github.com/treytartt/honeydue-api/internal/task"

// Use predicates
if task.IsCompleted(t) { ... }

// Use scopes
db.Scopes(task.ScopeOverdue(now)).Count(&count)

// Use categorization
column := task.CategorizeTask(t, 30)

Canonical Rules (Single Source of Truth)

These rules are defined in predicates/predicates.go and enforced everywhere:

Concept Definition SQL Equivalent
Completed NextDueDate == nil && len(Completions) > 0 next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion WHERE task_id = ?)
Active !IsCancelled && !IsArchived is_cancelled = false AND is_archived = false
In Progress InProgress == true in_progress = true
Effective Date NextDueDate ?? DueDate COALESCE(next_due_date, due_date)
Overdue Active && !Completed && EffectiveDate < now Active + NotCompleted + COALESCE(...) < ?
Due Soon Active && !Completed && now <= EffectiveDate < threshold Active + NotCompleted + COALESCE(...) >= ? AND COALESCE(...) < ?
Upcoming Everything else (future or no due date) Active + NotCompleted + COALESCE(...) >= ? OR (both NULL)

Kanban Column Priority Order

When categorizing a task, the chain evaluates in this priority order:

  1. Cancelled (highest) - IsCancelled == true
  2. Completed - NextDueDate == nil && len(Completions) > 0
  3. In Progress - InProgress == true
  4. Overdue - EffectiveDate < now
  5. Due Soon - now <= EffectiveDate < threshold
  6. Upcoming (lowest/default) - Everything else

Usage Examples

Counting Overdue Tasks (Efficient)

// Use scopes for database-level counting
var count int64
db.Model(&models.Task{}).
    Scopes(task.ScopeForResidences(residenceIDs), task.ScopeOverdue(now)).
    Count(&count)

Building a Kanban Board

// Load tasks with preloads
var tasks []models.Task
db.Preload("Completions").
    Scopes(task.ScopeForResidence(residenceID)).
    Find(&tasks)

// Categorize in memory using predicates
columns := task.CategorizeTasksIntoColumns(tasks, 30)

Checking Task State in Business Logic

// Use predicates for in-memory checks
if task.IsCompleted(t) {
    return []string{} // No actions for completed tasks
}

if task.IsOverdue(t, time.Now().UTC()) {
    return []string{"edit", "complete", "cancel", "mark_in_progress"}
}

Button Types for a Task

func GetButtonTypesForTask(t *models.Task, daysThreshold int) []string {
    now := time.Now().UTC()

    if predicates.IsCancelled(t) {
        return []string{"uncancel", "delete"}
    }
    if predicates.IsCompleted(t) {
        return []string{} // read-only
    }
    if predicates.IsInProgress(t) {
        return []string{"edit", "complete", "cancel"}
    }
    // Overdue, Due Soon, Upcoming all get the same buttons
    return []string{"edit", "complete", "cancel", "mark_in_progress"}
}

Why Two Layers (Predicates + Scopes)?

Layer Use Case Performance
Predicates In-memory checks on loaded objects Fast for small sets, already loaded data
Scopes Database-level filtering/counting Efficient for large datasets, avoids loading all records

Example: Counting overdue tasks across 1000 residences:

  • Predicates: Load all tasks into memory, filter in Go
  • Scopes: Execute SELECT COUNT(*) WHERE ... in database

Consistency Guarantees

  1. Scopes mirror predicates - Each scope produces the same logical result as its predicate counterpart
  2. Tests verify consistency - Unit tests ensure predicates and scopes produce identical results
  3. Cross-reference comments - Each scope has a comment linking to its predicate equivalent
  4. Single import point - The task package re-exports everything, making it easy to use consistently

Files That Were Refactored

The following files were updated to use the consolidated task logic:

File Changes
internal/repositories/task_repo.go Removed duplicate isTaskCompleted(), uses categorization and scopes
internal/services/task_button_types.go Uses predicates instead of inline logic
internal/dto/responses/task.go DetermineKanbanColumn delegates to categorization package
internal/worker/jobs/handler.go SQL queries documented with predicate references
internal/admin/handlers/dashboard_handler.go Uses scopes for task statistics
internal/services/residence_service.go Uses predicates for report generation
internal/models/task.go IsOverdue() and IsDueSoon() fixed to use EffectiveDate

Migration Notes

Old Pattern (Avoid)

// DON'T: Inline logic that may drift
if task.NextDueDate == nil && len(task.Completions) > 0 {
    // completed...
}

New Pattern (Preferred)

// DO: Use predicates
if predicates.IsCompleted(task) {
    // completed...
}

// Or via the task package
if task.IsCompleted(t) {
    // completed...
}

BEFORE writing any task-related code, ask yourself:

1. Does this logic already exist?

Check internal/task/predicates/predicates.go for:

  • State checks: IsCompleted, IsActive, IsCancelled, IsArchived, IsInProgress
  • Date logic: EffectiveDate, IsOverdue, IsDueSoon, IsUpcoming
  • Completion helpers: HasCompletions, CompletionCount, IsRecurring

2. Am I querying tasks from the database?

Use scopes from internal/task/scopes/scopes.go:

  • Filtering by state: ScopeActive, ScopeCompleted, ScopeNotCompleted
  • Filtering by date: ScopeOverdue(now), ScopeDueSoon(now, days), ScopeUpcoming(now, days)
  • Filtering by residence: ScopeForResidence(id), ScopeForResidences(ids)
  • Ordering: ScopeKanbanOrder

3. Am I categorizing tasks into kanban columns?

Use internal/task/categorization:

  • Single task: categorization.CategorizeTask(task, daysThreshold)
  • Multiple tasks: categorization.CategorizeTasksIntoColumns(tasks, daysThreshold)

4. Am I writing inline task logic?

STOP! If you're writing any of these patterns inline, use the existing functions instead:

// BAD: Inline completion check
if task.NextDueDate == nil && len(task.Completions) > 0 { ... }

// GOOD: Use predicate
if predicates.IsCompleted(task) { ... }

// BAD: Inline SQL for overdue
db.Where("due_date < ?", now)

// GOOD: Use scope
db.Scopes(scopes.ScopeOverdue(now))

// BAD: Manual kanban categorization
if task.IsCancelled { return "cancelled" }
else if ... { return "completed" }

// GOOD: Use categorization chain
column := categorization.CategorizeTask(task, 30)

5. Am I adding a NEW task state or concept?

If the existing predicates don't cover your use case:

  1. Add the predicate first in predicates/predicates.go
  2. Add the corresponding scope in scopes/scopes.go (if DB queries needed)
  3. Update the categorization chain if it affects kanban columns
  4. Add tests for both predicate and scope
  5. Update this documentation

Common Pitfalls

PostgreSQL DATE vs TIMESTAMP Comparison

When comparing DATE columns with time parameters, GORM passes time.Time as a string. PostgreSQL then compares dates, not timestamps:

-- This compares DATES (wrong for time-of-day precision):
'2025-12-07'::date < '2025-12-07 17:00:00'  -- FALSE!

-- This compares TIMESTAMPS (correct):
'2025-12-07'::date < '2025-12-07 17:00:00'::timestamp  -- TRUE

Solution: All date scopes use explicit ::timestamp casts:

Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", now)

Forgetting to Preload Completions

The IsCompleted predicate checks len(task.Completions) > 0. If you query tasks without preloading completions, this will always return false:

// BAD: Completions not loaded
db.Find(&tasks)
predicates.IsCompleted(task) // Always false!

// GOOD: Preload completions
db.Preload("Completions").Find(&tasks)
predicates.IsCompleted(task) // Correct result

In Progress Boolean Field

The IsInProgress predicate now uses a simple boolean field instead of a Status relation:

// IsInProgress uses the in_progress boolean field directly
// No preloading required for this check
predicates.IsInProgress(task) // Checks task.InProgress boolean

Quick Reference Import

For most files, use the convenience re-exports:

import "github.com/treytartt/honeydue-api/internal/task"

// Then use:
task.IsCompleted(t)
task.ScopeOverdue(now)
task.CategorizeTask(t, 30)

For files that only need predicates or only need scopes:

import "github.com/treytartt/honeydue-api/internal/task/predicates"
import "github.com/treytartt/honeydue-api/internal/task/scopes"
  • docs/TASK_KANBAN_CATEGORIZATION.md - Detailed kanban column logic
  • docs/TASK_KANBAN_LOGIC.md - Original kanban implementation notes