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>
304 lines
9.7 KiB
Go
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
|
|
}
|