Migrate from Gin to Echo framework and add comprehensive integration tests
Major changes: - Migrate all handlers from Gin to Echo framework - Add new apperrors, echohelpers, and validator packages - Update middleware for Echo compatibility - Add ArchivedHandler to task categorization chain (archived tasks go to cancelled_tasks column) - Add 6 new integration tests: - RecurringTaskLifecycle: NextDueDate advancement for weekly/monthly tasks - MultiUserSharing: Complex sharing with user removal - TaskStateTransitions: All state transitions and kanban column changes - DateBoundaryEdgeCases: Threshold boundary testing - CascadeOperations: Residence deletion cascade effects - MultiUserOperations: Shared residence collaboration - Add single-purpose repository functions for kanban columns (GetOverdueTasks, GetDueSoonTasks, etc.) - Fix RemoveUser route param mismatch (userId -> user_id) - Fix determineExpectedColumn helper to correctly prioritize in_progress over overdue 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,38 +36,59 @@ func (c KanbanColumn) String() string {
|
||||
// Context holds the data needed to categorize a task
|
||||
type Context struct {
|
||||
Task *models.Task
|
||||
Now time.Time
|
||||
Now time.Time // Always normalized to start of day
|
||||
DaysThreshold int
|
||||
}
|
||||
|
||||
// startOfDay normalizes a time to the start of that day (midnight)
|
||||
func startOfDay(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
// normalizeToTimezone converts a date to start of day in a specific timezone.
|
||||
// This is needed because task due dates are stored as midnight UTC, but we need
|
||||
// to compare them as calendar dates in the user's timezone.
|
||||
//
|
||||
// Example: A task due Dec 17 is stored as 2025-12-17 00:00:00 UTC.
|
||||
// For a user in Tokyo (UTC+9), we need to compare against Dec 17 in Tokyo time,
|
||||
// not against the UTC timestamp.
|
||||
func normalizeToTimezone(t time.Time, loc *time.Location) time.Time {
|
||||
// Extract the calendar date (year, month, day) from the time
|
||||
// regardless of its original timezone, then create midnight in target timezone
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
||||
}
|
||||
|
||||
// NewContext creates a new categorization context with sensible defaults.
|
||||
// Uses UTC time. For timezone-aware categorization, use NewContextWithTime.
|
||||
// Uses UTC time, normalized to start of day.
|
||||
// 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(),
|
||||
Now: startOfDay(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.
|
||||
// The time is normalized to start of day for consistent date comparisons.
|
||||
// Use this when you need timezone-aware categorization - pass the current time
|
||||
// in the user's timezone (it will be normalized to start of day).
|
||||
func NewContextWithTime(t *models.Task, daysThreshold int, now time.Time) *Context {
|
||||
if daysThreshold <= 0 {
|
||||
daysThreshold = 30
|
||||
}
|
||||
return &Context{
|
||||
Task: t,
|
||||
Now: now,
|
||||
Now: startOfDay(now),
|
||||
DaysThreshold: daysThreshold,
|
||||
}
|
||||
}
|
||||
|
||||
// ThresholdDate returns the date threshold for "due soon" categorization
|
||||
// (start of day + daysThreshold days)
|
||||
func (c *Context) ThresholdDate() time.Time {
|
||||
return c.Now.AddDate(0, 0, c.DaysThreshold)
|
||||
}
|
||||
@@ -118,8 +139,24 @@ func (h *CancelledHandler) Handle(ctx *Context) KanbanColumn {
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
// ArchivedHandler checks if the task is archived
|
||||
// Priority: 2 - Archived tasks go to cancelled column (both are "inactive" states)
|
||||
type ArchivedHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
|
||||
func (h *ArchivedHandler) Handle(ctx *Context) KanbanColumn {
|
||||
// Uses predicate: predicates.IsArchived
|
||||
// Archived tasks are placed in the cancelled column since both represent
|
||||
// "inactive" task states that are removed from active workflow
|
||||
if predicates.IsArchived(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
|
||||
// Priority: 3
|
||||
type CompletedHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
@@ -134,7 +171,7 @@ func (h *CompletedHandler) Handle(ctx *Context) KanbanColumn {
|
||||
}
|
||||
|
||||
// InProgressHandler checks if the task status is "In Progress"
|
||||
// Priority: 3
|
||||
// Priority: 4
|
||||
type InProgressHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
@@ -148,7 +185,7 @@ func (h *InProgressHandler) Handle(ctx *Context) KanbanColumn {
|
||||
}
|
||||
|
||||
// OverdueHandler checks if the task is overdue based on NextDueDate or DueDate
|
||||
// Priority: 4
|
||||
// Priority: 5
|
||||
type OverdueHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
@@ -158,14 +195,22 @@ func (h *OverdueHandler) Handle(ctx *Context) KanbanColumn {
|
||||
// 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) {
|
||||
if effectiveDate == nil {
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
// Normalize the effective date to the same timezone as ctx.Now for proper
|
||||
// calendar date comparison. Task dates are stored as UTC but represent
|
||||
// calendar dates (YYYY-MM-DD), not timestamps.
|
||||
normalizedEffective := normalizeToTimezone(*effectiveDate, ctx.Now.Location())
|
||||
if normalizedEffective.Before(ctx.Now) {
|
||||
return ColumnOverdue
|
||||
}
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
// DueSoonHandler checks if the task is due within the threshold period
|
||||
// Priority: 5
|
||||
// Priority: 6
|
||||
type DueSoonHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
@@ -173,16 +218,24 @@ type DueSoonHandler struct {
|
||||
func (h *DueSoonHandler) Handle(ctx *Context) KanbanColumn {
|
||||
// Uses predicate: predicates.EffectiveDate
|
||||
effectiveDate := predicates.EffectiveDate(ctx.Task)
|
||||
if effectiveDate == nil {
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
// Normalize the effective date to the same timezone as ctx.Now for proper
|
||||
// calendar date comparison. Task dates are stored as UTC but represent
|
||||
// calendar dates (YYYY-MM-DD), not timestamps.
|
||||
normalizedEffective := normalizeToTimezone(*effectiveDate, ctx.Now.Location())
|
||||
threshold := ctx.ThresholdDate()
|
||||
|
||||
if effectiveDate != nil && effectiveDate.Before(threshold) {
|
||||
if normalizedEffective.Before(threshold) {
|
||||
return ColumnDueSoon
|
||||
}
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
// UpcomingHandler is the final handler that catches all remaining tasks
|
||||
// Priority: 6 (lowest - default)
|
||||
// Priority: 7 (lowest - default)
|
||||
type UpcomingHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
@@ -206,14 +259,16 @@ type Chain struct {
|
||||
func NewChain() *Chain {
|
||||
// Build the chain in priority order (first handler has highest priority)
|
||||
cancelled := &CancelledHandler{}
|
||||
archived := &ArchivedHandler{}
|
||||
completed := &CompletedHandler{}
|
||||
inProgress := &InProgressHandler{}
|
||||
overdue := &OverdueHandler{}
|
||||
dueSoon := &DueSoonHandler{}
|
||||
upcoming := &UpcomingHandler{}
|
||||
|
||||
// Chain them together: cancelled -> completed -> inProgress -> overdue -> dueSoon -> upcoming
|
||||
cancelled.SetNext(completed).
|
||||
// Chain them together: cancelled -> archived -> completed -> inProgress -> overdue -> dueSoon -> upcoming
|
||||
cancelled.SetNext(archived).
|
||||
SetNext(completed).
|
||||
SetNext(inProgress).
|
||||
SetNext(overdue).
|
||||
SetNext(dueSoon).
|
||||
|
||||
@@ -233,3 +233,315 @@ func TestNewContext_DefaultThreshold(t *testing.T) {
|
||||
t.Errorf("NewContext with 45 threshold should be 45, got %d", ctx.DaysThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TIMEZONE TESTS
|
||||
// These tests verify that kanban categorization works correctly across timezones.
|
||||
// The key insight: a task's due date is stored as a date (YYYY-MM-DD), but
|
||||
// categorization depends on "what day is it NOW" in the user's timezone.
|
||||
// ============================================================================
|
||||
|
||||
func TestTimezone_SameTaskDifferentCategorization(t *testing.T) {
|
||||
// Scenario: A task due on Dec 17, 2025
|
||||
// At 11 PM UTC on Dec 16 (still Dec 16 in UTC)
|
||||
// But 8 AM on Dec 17 in Tokyo (+9 hours)
|
||||
// The task should be "due_soon" for UTC user but already in "due_soon" for Tokyo
|
||||
// (not overdue yet for either - both are still on or before Dec 17)
|
||||
|
||||
// Task due Dec 17, 2025 (stored as midnight UTC)
|
||||
taskDueDate := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
task := &models.Task{
|
||||
NextDueDate: timePtr(taskDueDate),
|
||||
IsCancelled: false,
|
||||
IsArchived: false,
|
||||
Completions: []models.TaskCompletion{},
|
||||
}
|
||||
|
||||
// User in UTC: It's Dec 16, 2025 at 11 PM UTC
|
||||
utcTime := time.Date(2025, 12, 16, 23, 0, 0, 0, time.UTC)
|
||||
|
||||
// User in Tokyo: Same instant but it's Dec 17, 2025 at 8 AM local
|
||||
tokyo, _ := time.LoadLocation("Asia/Tokyo")
|
||||
tokyoTime := utcTime.In(tokyo) // Same instant, different representation
|
||||
|
||||
// For UTC user: Dec 17 is tomorrow (1 day away) - should be due_soon
|
||||
resultUTC := categorization.CategorizeTaskWithTime(task, 30, utcTime)
|
||||
if resultUTC != categorization.ColumnDueSoon {
|
||||
t.Errorf("UTC (Dec 16): expected due_soon, got %v", resultUTC)
|
||||
}
|
||||
|
||||
// For Tokyo user: Dec 17 is TODAY - should still be due_soon (not overdue)
|
||||
resultTokyo := categorization.CategorizeTaskWithTime(task, 30, tokyoTime)
|
||||
if resultTokyo != categorization.ColumnDueSoon {
|
||||
t.Errorf("Tokyo (Dec 17): expected due_soon, got %v", resultTokyo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimezone_TaskBecomesOverdue_DifferentTimezones(t *testing.T) {
|
||||
// Scenario: A task due on Dec 16, 2025
|
||||
// At 11 PM UTC on Dec 16 (still Dec 16 in UTC) - due_soon
|
||||
// At 8 AM UTC on Dec 17 - now overdue
|
||||
// But for Tokyo user at 11 PM UTC (8 AM Dec 17 Tokyo) - already overdue
|
||||
|
||||
taskDueDate := time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
task := &models.Task{
|
||||
NextDueDate: timePtr(taskDueDate),
|
||||
IsCancelled: false,
|
||||
IsArchived: false,
|
||||
Completions: []models.TaskCompletion{},
|
||||
}
|
||||
|
||||
// Case 1: UTC user at 11 PM on Dec 16 - task is due TODAY, so due_soon
|
||||
utcDec16Evening := time.Date(2025, 12, 16, 23, 0, 0, 0, time.UTC)
|
||||
resultUTCEvening := categorization.CategorizeTaskWithTime(task, 30, utcDec16Evening)
|
||||
if resultUTCEvening != categorization.ColumnDueSoon {
|
||||
t.Errorf("UTC Dec 16 evening: expected due_soon, got %v", resultUTCEvening)
|
||||
}
|
||||
|
||||
// Case 2: UTC user at 8 AM on Dec 17 - task is now OVERDUE
|
||||
utcDec17Morning := time.Date(2025, 12, 17, 8, 0, 0, 0, time.UTC)
|
||||
resultUTCMorning := categorization.CategorizeTaskWithTime(task, 30, utcDec17Morning)
|
||||
if resultUTCMorning != categorization.ColumnOverdue {
|
||||
t.Errorf("UTC Dec 17 morning: expected overdue, got %v", resultUTCMorning)
|
||||
}
|
||||
|
||||
// Case 3: Tokyo user at the same instant as case 1
|
||||
// 11 PM UTC = 8 AM Dec 17 in Tokyo
|
||||
// For Tokyo user, Dec 16 was yesterday, so task is OVERDUE
|
||||
tokyo, _ := time.LoadLocation("Asia/Tokyo")
|
||||
tokyoTime := utcDec16Evening.In(tokyo)
|
||||
resultTokyo := categorization.CategorizeTaskWithTime(task, 30, tokyoTime)
|
||||
if resultTokyo != categorization.ColumnOverdue {
|
||||
t.Errorf("Tokyo (same instant as UTC Dec 16 evening): expected overdue, got %v", resultTokyo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimezone_InternationalDateLine(t *testing.T) {
|
||||
// Test across the international date line
|
||||
// Auckland (UTC+13) vs Honolulu (UTC-10)
|
||||
// 23 hour difference!
|
||||
|
||||
// Task due Dec 17, 2025
|
||||
taskDueDate := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
task := &models.Task{
|
||||
NextDueDate: timePtr(taskDueDate),
|
||||
IsCancelled: false,
|
||||
IsArchived: false,
|
||||
Completions: []models.TaskCompletion{},
|
||||
}
|
||||
|
||||
// At midnight UTC on Dec 17
|
||||
utcTime := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// Auckland: Dec 17 midnight UTC = Dec 17, 1 PM local (UTC+13)
|
||||
// Task is due today in Auckland - should be due_soon
|
||||
auckland, _ := time.LoadLocation("Pacific/Auckland")
|
||||
aucklandTime := utcTime.In(auckland)
|
||||
resultAuckland := categorization.CategorizeTaskWithTime(task, 30, aucklandTime)
|
||||
if resultAuckland != categorization.ColumnDueSoon {
|
||||
t.Errorf("Auckland (Dec 17, 1 PM): expected due_soon, got %v", resultAuckland)
|
||||
}
|
||||
|
||||
// Honolulu: Dec 17 midnight UTC = Dec 16, 2 PM local (UTC-10)
|
||||
// Task is due tomorrow in Honolulu - should be due_soon
|
||||
honolulu, _ := time.LoadLocation("Pacific/Honolulu")
|
||||
honoluluTime := utcTime.In(honolulu)
|
||||
resultHonolulu := categorization.CategorizeTaskWithTime(task, 30, honoluluTime)
|
||||
if resultHonolulu != categorization.ColumnDueSoon {
|
||||
t.Errorf("Honolulu (Dec 16, 2 PM): expected due_soon, got %v", resultHonolulu)
|
||||
}
|
||||
|
||||
// Now advance to Dec 18 midnight UTC
|
||||
// Auckland: Dec 18, 1 PM local - task due Dec 17 is now OVERDUE
|
||||
// Honolulu: Dec 17, 2 PM local - task due Dec 17 is TODAY (due_soon)
|
||||
utcDec18 := time.Date(2025, 12, 18, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
aucklandDec18 := utcDec18.In(auckland)
|
||||
resultAuckland2 := categorization.CategorizeTaskWithTime(task, 30, aucklandDec18)
|
||||
if resultAuckland2 != categorization.ColumnOverdue {
|
||||
t.Errorf("Auckland (Dec 18): expected overdue, got %v", resultAuckland2)
|
||||
}
|
||||
|
||||
honoluluDec17 := utcDec18.In(honolulu)
|
||||
resultHonolulu2 := categorization.CategorizeTaskWithTime(task, 30, honoluluDec17)
|
||||
if resultHonolulu2 != categorization.ColumnDueSoon {
|
||||
t.Errorf("Honolulu (Dec 17): expected due_soon, got %v", resultHonolulu2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimezone_DueSoonThreshold_CrossesTimezones(t *testing.T) {
|
||||
// Test that the 30-day threshold is calculated correctly in different timezones
|
||||
|
||||
// Task due 29 days from now (within threshold for both timezones)
|
||||
// Task due 31 days from now (outside threshold)
|
||||
|
||||
now := time.Date(2025, 12, 16, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Task due in 29 days
|
||||
due29Days := time.Date(2026, 1, 14, 0, 0, 0, 0, time.UTC)
|
||||
task29 := &models.Task{
|
||||
NextDueDate: timePtr(due29Days),
|
||||
IsCancelled: false,
|
||||
IsArchived: false,
|
||||
Completions: []models.TaskCompletion{},
|
||||
}
|
||||
|
||||
// Task due in 31 days
|
||||
due31Days := time.Date(2026, 1, 16, 0, 0, 0, 0, time.UTC)
|
||||
task31 := &models.Task{
|
||||
NextDueDate: timePtr(due31Days),
|
||||
IsCancelled: false,
|
||||
IsArchived: false,
|
||||
Completions: []models.TaskCompletion{},
|
||||
}
|
||||
|
||||
// UTC user
|
||||
result29UTC := categorization.CategorizeTaskWithTime(task29, 30, now)
|
||||
if result29UTC != categorization.ColumnDueSoon {
|
||||
t.Errorf("29 days (UTC): expected due_soon, got %v", result29UTC)
|
||||
}
|
||||
|
||||
result31UTC := categorization.CategorizeTaskWithTime(task31, 30, now)
|
||||
if result31UTC != categorization.ColumnUpcoming {
|
||||
t.Errorf("31 days (UTC): expected upcoming, got %v", result31UTC)
|
||||
}
|
||||
|
||||
// Tokyo user at same instant
|
||||
tokyo, _ := time.LoadLocation("Asia/Tokyo")
|
||||
tokyoNow := now.In(tokyo)
|
||||
|
||||
result29Tokyo := categorization.CategorizeTaskWithTime(task29, 30, tokyoNow)
|
||||
if result29Tokyo != categorization.ColumnDueSoon {
|
||||
t.Errorf("29 days (Tokyo): expected due_soon, got %v", result29Tokyo)
|
||||
}
|
||||
|
||||
result31Tokyo := categorization.CategorizeTaskWithTime(task31, 30, tokyoNow)
|
||||
if result31Tokyo != categorization.ColumnUpcoming {
|
||||
t.Errorf("31 days (Tokyo): expected upcoming, got %v", result31Tokyo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimezone_StartOfDayNormalization(t *testing.T) {
|
||||
// Test that times are normalized to start of day in the given timezone
|
||||
|
||||
// A task due Dec 17
|
||||
taskDueDate := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
|
||||
task := &models.Task{
|
||||
NextDueDate: timePtr(taskDueDate),
|
||||
IsCancelled: false,
|
||||
IsArchived: false,
|
||||
Completions: []models.TaskCompletion{},
|
||||
}
|
||||
|
||||
// Test that different times on the SAME DAY produce the SAME result
|
||||
// All of these should evaluate to "Dec 16" (today), making Dec 17 "due_soon"
|
||||
times := []time.Time{
|
||||
time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC), // Midnight
|
||||
time.Date(2025, 12, 16, 6, 0, 0, 0, time.UTC), // 6 AM
|
||||
time.Date(2025, 12, 16, 12, 0, 0, 0, time.UTC), // Noon
|
||||
time.Date(2025, 12, 16, 18, 0, 0, 0, time.UTC), // 6 PM
|
||||
time.Date(2025, 12, 16, 23, 59, 59, 0, time.UTC), // Just before midnight
|
||||
}
|
||||
|
||||
for _, nowTime := range times {
|
||||
result := categorization.CategorizeTaskWithTime(task, 30, nowTime)
|
||||
if result != categorization.ColumnDueSoon {
|
||||
t.Errorf("At %v: expected due_soon, got %v", nowTime.Format("15:04:05"), result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimezone_DST_Transitions(t *testing.T) {
|
||||
// Test behavior during daylight saving time transitions
|
||||
// Los Angeles transitions from PDT to PST in early November
|
||||
|
||||
la, err := time.LoadLocation("America/Los_Angeles")
|
||||
if err != nil {
|
||||
t.Skip("America/Los_Angeles timezone not available")
|
||||
}
|
||||
|
||||
// Task due Nov 3, 2025 (DST ends in LA on Nov 2, 2025)
|
||||
taskDueDate := time.Date(2025, 11, 3, 0, 0, 0, 0, time.UTC)
|
||||
task := &models.Task{
|
||||
NextDueDate: timePtr(taskDueDate),
|
||||
IsCancelled: false,
|
||||
IsArchived: false,
|
||||
Completions: []models.TaskCompletion{},
|
||||
}
|
||||
|
||||
// Nov 2 at 11 PM LA time (during DST transition)
|
||||
// This should still be Nov 2, so Nov 3 is tomorrow (due_soon)
|
||||
laNov2Late := time.Date(2025, 11, 2, 23, 0, 0, 0, la)
|
||||
result := categorization.CategorizeTaskWithTime(task, 30, laNov2Late)
|
||||
if result != categorization.ColumnDueSoon {
|
||||
t.Errorf("Nov 2 late evening LA: expected due_soon, got %v", result)
|
||||
}
|
||||
|
||||
// Nov 3 at 1 AM LA time (after DST ends)
|
||||
// This is Nov 3, so task is due today (due_soon)
|
||||
laNov3Early := time.Date(2025, 11, 3, 1, 0, 0, 0, la)
|
||||
result = categorization.CategorizeTaskWithTime(task, 30, laNov3Early)
|
||||
if result != categorization.ColumnDueSoon {
|
||||
t.Errorf("Nov 3 early morning LA: expected due_soon, got %v", result)
|
||||
}
|
||||
|
||||
// Nov 4 at any time (after due date)
|
||||
laNov4 := time.Date(2025, 11, 4, 8, 0, 0, 0, la)
|
||||
result = categorization.CategorizeTaskWithTime(task, 30, laNov4)
|
||||
if result != categorization.ColumnOverdue {
|
||||
t.Errorf("Nov 4 LA: expected overdue, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimezone_MultipleTasksIntoColumns(t *testing.T) {
|
||||
// Test CategorizeTasksIntoColumnsWithTime with timezone-aware categorization
|
||||
|
||||
// Tasks with various due dates
|
||||
dec16 := time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC)
|
||||
dec17 := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
|
||||
jan15 := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
tasks := []models.Task{
|
||||
{BaseModel: models.BaseModel{ID: 1}, NextDueDate: timePtr(dec16)}, // Due Dec 16
|
||||
{BaseModel: models.BaseModel{ID: 2}, NextDueDate: timePtr(dec17)}, // Due Dec 17
|
||||
{BaseModel: models.BaseModel{ID: 3}, NextDueDate: timePtr(jan15)}, // Due Jan 15
|
||||
}
|
||||
|
||||
// Categorize as of Dec 17 midnight UTC
|
||||
now := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
|
||||
result := categorization.CategorizeTasksIntoColumnsWithTime(tasks, 30, now)
|
||||
|
||||
// Dec 16 should be overdue (yesterday)
|
||||
if len(result[categorization.ColumnOverdue]) != 1 || result[categorization.ColumnOverdue][0].ID != 1 {
|
||||
t.Errorf("Expected task 1 (Dec 16) in overdue column, got %d tasks", len(result[categorization.ColumnOverdue]))
|
||||
}
|
||||
|
||||
// Dec 17 (today) and Jan 15 (29 days away) should both be in due_soon
|
||||
// Dec 17 to Jan 15 = 29 days (Dec 17-31 = 14 days, Jan 1-15 = 15 days)
|
||||
dueSoonTasks := result[categorization.ColumnDueSoon]
|
||||
if len(dueSoonTasks) != 2 {
|
||||
t.Errorf("Expected 2 tasks in due_soon column, got %d", len(dueSoonTasks))
|
||||
}
|
||||
|
||||
// Verify both task 2 and 3 are in due_soon
|
||||
foundTask2 := false
|
||||
foundTask3 := false
|
||||
for _, task := range dueSoonTasks {
|
||||
if task.ID == 2 {
|
||||
foundTask2 = true
|
||||
}
|
||||
if task.ID == 3 {
|
||||
foundTask3 = true
|
||||
}
|
||||
}
|
||||
|
||||
if !foundTask2 {
|
||||
t.Errorf("Expected task 2 (Dec 17) in due_soon column")
|
||||
}
|
||||
if !foundTask3 {
|
||||
t.Errorf("Expected task 3 (Jan 15) in due_soon column")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,8 +570,9 @@ func TestAllThreeLayersMatch(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestSameDayOverdueConsistency is a regression test for the DATE vs TIMESTAMP bug.
|
||||
// It verifies all three layers handle same-day tasks consistently.
|
||||
// TestSameDayOverdueConsistency is a regression test for day-based overdue logic.
|
||||
// With day-based comparisons, a task due TODAY (at any time) is NOT overdue during that day.
|
||||
// It only becomes overdue the NEXT day. This test verifies all three layers agree.
|
||||
func TestSameDayOverdueConsistency(t *testing.T) {
|
||||
if testDB == nil {
|
||||
t.Skip("Database not available")
|
||||
@@ -606,17 +607,16 @@ func TestSameDayOverdueConsistency(t *testing.T) {
|
||||
|
||||
categorizationResult := categorization.CategorizeTask(&loadedTask, 30) == categorization.ColumnOverdue
|
||||
|
||||
// If current time is after midnight, all should say overdue
|
||||
if now.After(todayMidnight) {
|
||||
if !predicateResult {
|
||||
t.Error("Predicate says NOT overdue, but time is after midnight")
|
||||
}
|
||||
if !scopeResult {
|
||||
t.Error("Scope says NOT overdue, but time is after midnight")
|
||||
}
|
||||
if !categorizationResult {
|
||||
t.Error("Categorization says NOT overdue, but time is after midnight")
|
||||
}
|
||||
// With day-based comparison: task due TODAY is NOT overdue during that day.
|
||||
// All three layers should say NOT overdue.
|
||||
if predicateResult {
|
||||
t.Error("Predicate incorrectly says overdue for same-day task")
|
||||
}
|
||||
if scopeResult {
|
||||
t.Error("Scope incorrectly says overdue for same-day task")
|
||||
}
|
||||
if categorizationResult {
|
||||
t.Error("Categorization incorrectly says overdue for same-day task")
|
||||
}
|
||||
|
||||
// Most importantly: all three must agree
|
||||
|
||||
@@ -91,16 +91,18 @@ func EffectiveDate(task *models.Task) *time.Time {
|
||||
return task.DueDate
|
||||
}
|
||||
|
||||
// IsOverdue returns true if the task's effective date is in the past.
|
||||
// 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 given time
|
||||
// - 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) < ?
|
||||
// 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 {
|
||||
@@ -111,20 +113,25 @@ func IsOverdue(task *models.Task, now time.Time) bool {
|
||||
if effectiveDate == nil {
|
||||
return false
|
||||
}
|
||||
return effectiveDate.Before(now)
|
||||
// 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 >= now AND < (now + daysThreshold)
|
||||
// - 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) >= ?
|
||||
// AND COALESCE(next_due_date, due_date) < ?
|
||||
// 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 {
|
||||
@@ -135,18 +142,22 @@ func IsDueSoon(task *models.Task, now time.Time, daysThreshold int) bool {
|
||||
if effectiveDate == nil {
|
||||
return false
|
||||
}
|
||||
threshold := now.AddDate(0, 0, daysThreshold)
|
||||
// Due soon = not overdue AND before threshold
|
||||
return !effectiveDate.Before(now) && effectiveDate.Before(threshold)
|
||||
// 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 >= (now + daysThreshold)
|
||||
// - 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) {
|
||||
@@ -156,7 +167,9 @@ func IsUpcoming(task *models.Task, now time.Time, daysThreshold int) bool {
|
||||
if effectiveDate == nil {
|
||||
return true // No due date = upcoming
|
||||
}
|
||||
threshold := now.AddDate(0, 0, daysThreshold)
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -187,6 +187,8 @@ func TestIsOverdue(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
yesterday := now.AddDate(0, 0, -1)
|
||||
tomorrow := now.AddDate(0, 0, 1)
|
||||
// Start of today - this is what a DATE column stores (midnight)
|
||||
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -216,6 +218,17 @@ func TestIsOverdue(t *testing.T) {
|
||||
now: now,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "not overdue: task due today (start of day)",
|
||||
task: &models.Task{
|
||||
NextDueDate: timePtr(startOfToday),
|
||||
IsCancelled: false,
|
||||
IsArchived: false,
|
||||
Completions: []models.TaskCompletion{},
|
||||
},
|
||||
now: now, // Current time during the day
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "not overdue: cancelled task",
|
||||
task: &models.Task{
|
||||
@@ -291,6 +304,8 @@ func TestIsDueSoon(t *testing.T) {
|
||||
yesterday := now.AddDate(0, 0, -1)
|
||||
in5Days := now.AddDate(0, 0, 5)
|
||||
in60Days := now.AddDate(0, 0, 60)
|
||||
// Start of today - this is what a DATE column stores (midnight)
|
||||
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -311,6 +326,18 @@ func TestIsDueSoon(t *testing.T) {
|
||||
daysThreshold: 30,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "due soon: task due today (start of day)",
|
||||
task: &models.Task{
|
||||
NextDueDate: timePtr(startOfToday),
|
||||
IsCancelled: false,
|
||||
IsArchived: false,
|
||||
Completions: []models.TaskCompletion{},
|
||||
},
|
||||
now: now, // Current time during the day
|
||||
daysThreshold: 30,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "not due soon: beyond threshold",
|
||||
task: &models.Task{
|
||||
|
||||
@@ -98,66 +98,65 @@ func ScopeNotInProgress(db *gorm.DB) *gorm.DB {
|
||||
// ScopeOverdue returns a scope for overdue tasks.
|
||||
//
|
||||
// A task is overdue when its effective date (COALESCE(next_due_date, due_date))
|
||||
// is before the given time, and it's active and not completed.
|
||||
// is before the start of the given day, and it's active and not completed.
|
||||
//
|
||||
// Note: A task due "today" is NOT overdue. It becomes overdue tomorrow.
|
||||
//
|
||||
// Predicate equivalent: IsOverdue(task, now)
|
||||
//
|
||||
// SQL: COALESCE(next_due_date, due_date) < ?::timestamp AND active AND not_completed
|
||||
//
|
||||
// NOTE: We explicitly cast to timestamp because PostgreSQL DATE columns compared
|
||||
// against string literals (which is how GORM passes time.Time) use date comparison,
|
||||
// not timestamp comparison. For example:
|
||||
// - '2025-12-07'::date < '2025-12-07 17:00:00' = false (compares dates only)
|
||||
// - '2025-12-07'::date < '2025-12-07 17:00:00'::timestamp = true (compares timestamp)
|
||||
// SQL: COALESCE(next_due_date, due_date) < ? AND active AND not_completed
|
||||
func ScopeOverdue(now time.Time) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
// Compute start of day in Go for database-agnostic comparison
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
return db.Scopes(ScopeActive, ScopeNotCompleted).
|
||||
Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", now)
|
||||
Where("COALESCE(next_due_date, due_date) < ?", startOfDay)
|
||||
}
|
||||
}
|
||||
|
||||
// ScopeDueSoon returns a scope for tasks due within the threshold.
|
||||
//
|
||||
// A task is "due soon" when its effective date is >= now AND < (now + threshold),
|
||||
// A task is "due soon" when its effective date is >= start of today AND < start of (today + threshold),
|
||||
// and it's active and not completed.
|
||||
//
|
||||
// Note: Uses day-level comparisons so tasks due "today" are included.
|
||||
//
|
||||
// Predicate equivalent: IsDueSoon(task, now, daysThreshold)
|
||||
//
|
||||
// SQL: COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp
|
||||
// SQL: COALESCE(next_due_date, due_date) >= ? AND COALESCE(next_due_date, due_date) < ?
|
||||
//
|
||||
// AND COALESCE(next_due_date, due_date)::timestamp < ?::timestamp
|
||||
// AND active AND not_completed
|
||||
//
|
||||
// NOTE: We explicitly cast to timestamp for consistent comparison with DATE columns.
|
||||
// See ScopeOverdue for detailed explanation.
|
||||
func ScopeDueSoon(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
threshold := now.AddDate(0, 0, daysThreshold)
|
||||
// Compute start of day and threshold in Go for database-agnostic comparison
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
threshold := startOfDay.AddDate(0, 0, daysThreshold)
|
||||
return db.Scopes(ScopeActive, ScopeNotCompleted).
|
||||
Where("COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp", now).
|
||||
Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", threshold)
|
||||
Where("COALESCE(next_due_date, due_date) >= ?", startOfDay).
|
||||
Where("COALESCE(next_due_date, due_date) < ?", threshold)
|
||||
}
|
||||
}
|
||||
|
||||
// ScopeUpcoming returns a scope for tasks due after the threshold or with no due date.
|
||||
//
|
||||
// A task is "upcoming" when its effective date is >= (now + threshold) OR is null,
|
||||
// A task is "upcoming" when its effective date is >= start of (today + threshold) OR is null,
|
||||
// and it's active and not completed.
|
||||
//
|
||||
// Note: Uses start of day for comparisons for consistency with other scopes.
|
||||
//
|
||||
// Predicate equivalent: IsUpcoming(task, now, daysThreshold)
|
||||
//
|
||||
// SQL: (COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp OR (next_due_date IS NULL AND due_date IS NULL))
|
||||
// SQL: (COALESCE(next_due_date, due_date) >= ? OR (next_due_date IS NULL AND due_date IS NULL))
|
||||
//
|
||||
// AND active AND not_completed
|
||||
//
|
||||
// NOTE: We explicitly cast to timestamp for consistent comparison with DATE columns.
|
||||
// See ScopeOverdue for detailed explanation.
|
||||
func ScopeUpcoming(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
threshold := now.AddDate(0, 0, daysThreshold)
|
||||
// Compute threshold as start of day + N days in Go for database-agnostic comparison
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
threshold := startOfDay.AddDate(0, 0, daysThreshold)
|
||||
return db.Scopes(ScopeActive, ScopeNotCompleted).
|
||||
Where(
|
||||
"COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp OR (next_due_date IS NULL AND due_date IS NULL)",
|
||||
"COALESCE(next_due_date, due_date) >= ? OR (next_due_date IS NULL AND due_date IS NULL)",
|
||||
threshold,
|
||||
)
|
||||
}
|
||||
@@ -165,17 +164,12 @@ func ScopeUpcoming(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB
|
||||
|
||||
// ScopeDueInRange returns a scope for tasks with effective date in a range.
|
||||
//
|
||||
// SQL: COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp
|
||||
//
|
||||
// AND COALESCE(next_due_date, due_date)::timestamp < ?::timestamp
|
||||
//
|
||||
// NOTE: We explicitly cast to timestamp for consistent comparison with DATE columns.
|
||||
// See ScopeOverdue for detailed explanation.
|
||||
// SQL: COALESCE(next_due_date, due_date) >= ? AND COALESCE(next_due_date, due_date) < ?
|
||||
func ScopeDueInRange(start, end time.Time) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
return db.
|
||||
Where("COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp", start).
|
||||
Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", end)
|
||||
Where("COALESCE(next_due_date, due_date) >= ?", start).
|
||||
Where("COALESCE(next_due_date, due_date) < ?", end)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -356,8 +356,9 @@ func TestScopeOverdueMatchesPredicate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopeOverdueWithSameDayTask tests the DATE vs TIMESTAMP comparison edge case
|
||||
// This is a regression test for the bug where tasks due "today" were not counted as overdue
|
||||
// TestScopeOverdueWithSameDayTask tests day-based overdue comparison.
|
||||
// With day-based logic, a task due TODAY is NOT overdue during that same day.
|
||||
// It only becomes overdue the NEXT day. Both scope and predicate should agree.
|
||||
func TestScopeOverdueWithSameDayTask(t *testing.T) {
|
||||
if testDB == nil {
|
||||
t.Skip("Database not available")
|
||||
@@ -397,16 +398,15 @@ func TestScopeOverdueWithSameDayTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Both should agree: if it's past midnight, the task due at midnight is overdue
|
||||
// Both should agree: with day-based comparison, task due today is NOT overdue
|
||||
if len(scopeResults) != len(predicateResults) {
|
||||
t.Errorf("DATE vs TIMESTAMP mismatch! Scope returned %d, predicate returned %d",
|
||||
t.Errorf("Scope/predicate mismatch! Scope returned %d, predicate returned %d",
|
||||
len(scopeResults), len(predicateResults))
|
||||
t.Logf("This indicates the PostgreSQL DATE/TIMESTAMP comparison bug may have returned")
|
||||
}
|
||||
|
||||
// If current time is after midnight, task should be overdue
|
||||
if now.After(todayMidnight) && len(scopeResults) != 1 {
|
||||
t.Errorf("Task due at midnight should be overdue after midnight, got %d results", len(scopeResults))
|
||||
// With day-based comparison, task due today should NOT be overdue (it's due soon)
|
||||
if len(scopeResults) != 0 {
|
||||
t.Errorf("Task due today should NOT be overdue, got %d results (expected 0)", len(scopeResults))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user