Files
honeyDueAPI/internal/task/categorization/chain.go
Trey t 684856e0e9 Add timezone-aware overdue task detection
Fix issue where tasks showed as "Overdue" on the server while displaying
"Tomorrow" on the client due to timezone differences between server (UTC)
and user's local timezone.

Changes:
- Add X-Timezone header support to extract user's timezone from requests
- Add TimezoneMiddleware to parse timezone and calculate user's local "today"
- Update task categorization to accept custom time for accurate date comparisons
- Update repository, service, and handler layers to pass timezone-aware time
- Update CORS to allow X-Timezone header

The client now sends the user's IANA timezone (e.g., "America/Los_Angeles")
and the server uses it to determine if a task is overdue based on the
user's local date, not UTC.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 00:04:09 -06:00

304 lines
9.7 KiB
Go

// Package categorization implements the Chain of Responsibility pattern for
// determining which kanban column a task belongs to.
//
// The chain evaluates tasks in a specific priority order, with each handler
// checking if the task matches its criteria. If a handler matches, it returns
// the column name; otherwise, it passes to the next handler in the chain.
//
// IMPORTANT: This package uses predicates from the parent task package as the
// single source of truth for task logic. Do NOT duplicate logic here.
package categorization
import (
"time"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/task/predicates"
)
// KanbanColumn represents the possible kanban column names
type KanbanColumn string
const (
ColumnOverdue KanbanColumn = "overdue_tasks"
ColumnDueSoon KanbanColumn = "due_soon_tasks"
ColumnUpcoming KanbanColumn = "upcoming_tasks"
ColumnInProgress KanbanColumn = "in_progress_tasks"
ColumnCompleted KanbanColumn = "completed_tasks"
ColumnCancelled KanbanColumn = "cancelled_tasks"
)
// String returns the string representation of the column
func (c KanbanColumn) String() string {
return string(c)
}
// Context holds the data needed to categorize a task
type Context struct {
Task *models.Task
Now time.Time
DaysThreshold int
}
// NewContext creates a new categorization context with sensible defaults.
// Uses UTC time. For timezone-aware categorization, use NewContextWithTime.
func NewContext(t *models.Task, daysThreshold int) *Context {
if daysThreshold <= 0 {
daysThreshold = 30
}
return &Context{
Task: t,
Now: time.Now().UTC(),
DaysThreshold: daysThreshold,
}
}
// NewContextWithTime creates a new categorization context with a specific time.
// Use this when you need timezone-aware categorization - pass the start of day
// in the user's timezone.
func NewContextWithTime(t *models.Task, daysThreshold int, now time.Time) *Context {
if daysThreshold <= 0 {
daysThreshold = 30
}
return &Context{
Task: t,
Now: now,
DaysThreshold: daysThreshold,
}
}
// ThresholdDate returns the date threshold for "due soon" categorization
func (c *Context) ThresholdDate() time.Time {
return c.Now.AddDate(0, 0, c.DaysThreshold)
}
// Handler defines the interface for task categorization handlers
type Handler interface {
// SetNext sets the next handler in the chain
SetNext(handler Handler) Handler
// Handle processes the task and returns the column name if matched,
// or delegates to the next handler
Handle(ctx *Context) KanbanColumn
}
// BaseHandler provides default chaining behavior
type BaseHandler struct {
next Handler
}
// SetNext sets the next handler and returns it for fluent chaining
func (h *BaseHandler) SetNext(handler Handler) Handler {
h.next = handler
return handler
}
// HandleNext delegates to the next handler or returns default
func (h *BaseHandler) HandleNext(ctx *Context) KanbanColumn {
if h.next != nil {
return h.next.Handle(ctx)
}
return ColumnUpcoming // Default fallback
}
// === Concrete Handlers ===
// Each handler uses predicates from the task package as the source of truth.
// CancelledHandler checks if the task is cancelled
// Priority: 1 (highest - checked first)
type CancelledHandler struct {
BaseHandler
}
func (h *CancelledHandler) Handle(ctx *Context) KanbanColumn {
// Uses predicate: predicates.IsCancelled
if predicates.IsCancelled(ctx.Task) {
return ColumnCancelled
}
return h.HandleNext(ctx)
}
// CompletedHandler checks if the task is completed (one-time task with completions and no next due date)
// Priority: 2
type CompletedHandler struct {
BaseHandler
}
func (h *CompletedHandler) Handle(ctx *Context) KanbanColumn {
// Uses predicate: predicates.IsCompleted
// A task is completed if NextDueDate is nil AND has at least one completion
if predicates.IsCompleted(ctx.Task) {
return ColumnCompleted
}
return h.HandleNext(ctx)
}
// InProgressHandler checks if the task status is "In Progress"
// Priority: 3
type InProgressHandler struct {
BaseHandler
}
func (h *InProgressHandler) Handle(ctx *Context) KanbanColumn {
// Uses predicate: predicates.IsInProgress
if predicates.IsInProgress(ctx.Task) {
return ColumnInProgress
}
return h.HandleNext(ctx)
}
// OverdueHandler checks if the task is overdue based on NextDueDate or DueDate
// Priority: 4
type OverdueHandler struct {
BaseHandler
}
func (h *OverdueHandler) Handle(ctx *Context) KanbanColumn {
// Uses predicate: predicates.EffectiveDate
// Note: We don't use predicates.IsOverdue here because the chain has already
// filtered out cancelled and completed tasks. We just need the date check.
effectiveDate := predicates.EffectiveDate(ctx.Task)
if effectiveDate != nil && effectiveDate.Before(ctx.Now) {
return ColumnOverdue
}
return h.HandleNext(ctx)
}
// DueSoonHandler checks if the task is due within the threshold period
// Priority: 5
type DueSoonHandler struct {
BaseHandler
}
func (h *DueSoonHandler) Handle(ctx *Context) KanbanColumn {
// Uses predicate: predicates.EffectiveDate
effectiveDate := predicates.EffectiveDate(ctx.Task)
threshold := ctx.ThresholdDate()
if effectiveDate != nil && effectiveDate.Before(threshold) {
return ColumnDueSoon
}
return h.HandleNext(ctx)
}
// UpcomingHandler is the final handler that catches all remaining tasks
// Priority: 6 (lowest - default)
type UpcomingHandler struct {
BaseHandler
}
func (h *UpcomingHandler) Handle(ctx *Context) KanbanColumn {
// This is the default catch-all for tasks that:
// - Are not cancelled, completed, or in progress
// - Are not overdue or due soon
// - Have a due date far in the future OR no due date at all
return ColumnUpcoming
}
// === Chain Builder ===
// Chain manages the categorization chain
type Chain struct {
head Handler
}
// NewChain creates a new categorization chain with handlers in priority order
func NewChain() *Chain {
// Build the chain in priority order (first handler has highest priority)
cancelled := &CancelledHandler{}
completed := &CompletedHandler{}
inProgress := &InProgressHandler{}
overdue := &OverdueHandler{}
dueSoon := &DueSoonHandler{}
upcoming := &UpcomingHandler{}
// Chain them together: cancelled -> completed -> inProgress -> overdue -> dueSoon -> upcoming
cancelled.SetNext(completed).
SetNext(inProgress).
SetNext(overdue).
SetNext(dueSoon).
SetNext(upcoming)
return &Chain{head: cancelled}
}
// Categorize determines which kanban column a task belongs to.
// Uses UTC time. For timezone-aware categorization, use CategorizeWithTime.
func (c *Chain) Categorize(t *models.Task, daysThreshold int) KanbanColumn {
ctx := NewContext(t, daysThreshold)
return c.head.Handle(ctx)
}
// CategorizeWithTime determines which kanban column a task belongs to using a specific time.
// Use this when you need timezone-aware categorization - pass the start of day
// in the user's timezone.
func (c *Chain) CategorizeWithTime(t *models.Task, daysThreshold int, now time.Time) KanbanColumn {
ctx := NewContextWithTime(t, daysThreshold, now)
return c.head.Handle(ctx)
}
// CategorizeWithContext uses a pre-built context for categorization
func (c *Chain) CategorizeWithContext(ctx *Context) KanbanColumn {
return c.head.Handle(ctx)
}
// === Convenience Functions ===
// defaultChain is a singleton chain instance for convenience
var defaultChain = NewChain()
// DetermineKanbanColumn is a convenience function that uses the default chain.
// Uses UTC time. For timezone-aware categorization, use DetermineKanbanColumnWithTime.
func DetermineKanbanColumn(t *models.Task, daysThreshold int) string {
return defaultChain.Categorize(t, daysThreshold).String()
}
// DetermineKanbanColumnWithTime is a convenience function that uses a specific time.
// Use this when you need timezone-aware categorization.
func DetermineKanbanColumnWithTime(t *models.Task, daysThreshold int, now time.Time) string {
return defaultChain.CategorizeWithTime(t, daysThreshold, now).String()
}
// CategorizeTask is an alias for DetermineKanbanColumn with a more descriptive name.
// Uses UTC time. For timezone-aware categorization, use CategorizeTaskWithTime.
func CategorizeTask(t *models.Task, daysThreshold int) KanbanColumn {
return defaultChain.Categorize(t, daysThreshold)
}
// CategorizeTaskWithTime categorizes a task using a specific time.
// Use this when you need timezone-aware categorization - pass the start of day
// in the user's timezone.
func CategorizeTaskWithTime(t *models.Task, daysThreshold int, now time.Time) KanbanColumn {
return defaultChain.CategorizeWithTime(t, daysThreshold, now)
}
// CategorizeTasksIntoColumns categorizes multiple tasks into their respective columns.
// Uses UTC time. For timezone-aware categorization, use CategorizeTasksIntoColumnsWithTime.
func CategorizeTasksIntoColumns(tasks []models.Task, daysThreshold int) map[KanbanColumn][]models.Task {
return CategorizeTasksIntoColumnsWithTime(tasks, daysThreshold, time.Now().UTC())
}
// CategorizeTasksIntoColumnsWithTime categorizes multiple tasks using a specific time.
// Use this when you need timezone-aware categorization - pass the start of day
// in the user's timezone.
func CategorizeTasksIntoColumnsWithTime(tasks []models.Task, daysThreshold int, now time.Time) map[KanbanColumn][]models.Task {
result := make(map[KanbanColumn][]models.Task)
// Initialize all columns with empty slices
for _, col := range []KanbanColumn{
ColumnOverdue, ColumnDueSoon, ColumnUpcoming,
ColumnInProgress, ColumnCompleted, ColumnCancelled,
} {
result[col] = make([]models.Task, 0)
}
// Categorize each task
chain := NewChain()
for _, t := range tasks {
column := chain.CategorizeWithTime(&t, daysThreshold, now)
result[column] = append(result[column], t)
}
return result
}