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>
This commit is contained in:
Trey t
2025-12-13 00:04:09 -06:00
parent a568658b58
commit 684856e0e9
8 changed files with 206 additions and 45 deletions

View File

@@ -40,7 +40,8 @@ type Context struct {
DaysThreshold int
}
// NewContext creates a new categorization context with sensible defaults
// 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
@@ -52,6 +53,20 @@ func NewContext(t *models.Task, daysThreshold int) *Context {
}
}
// 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)
@@ -207,12 +222,21 @@ func NewChain() *Chain {
return &Chain{head: cancelled}
}
// Categorize determines which kanban column a task belongs to
// 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)
@@ -223,18 +247,41 @@ func (c *Chain) CategorizeWithContext(ctx *Context) KanbanColumn {
// defaultChain is a singleton chain instance for convenience
var defaultChain = NewChain()
// DetermineKanbanColumn is a convenience function that uses the default chain
// 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()
}
// CategorizeTask is an alias for DetermineKanbanColumn with a more descriptive name
// 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)
}
// CategorizeTasksIntoColumns categorizes multiple tasks into their respective columns
// 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
@@ -248,7 +295,7 @@ func CategorizeTasksIntoColumns(tasks []models.Task, daysThreshold int) map[Kanb
// Categorize each task
chain := NewChain()
for _, t := range tasks {
column := chain.Categorize(&t, daysThreshold)
column := chain.CategorizeWithTime(&t, daysThreshold, now)
result[column] = append(result[column], t)
}