package categorization_test import ( "testing" "time" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/task/categorization" ) // Helper to create a time pointer func timePtr(t time.Time) *time.Time { return &t } func TestCategorizeTask_PriorityOrder(t *testing.T) { now := time.Now().UTC() yesterday := now.AddDate(0, 0, -1) in5Days := now.AddDate(0, 0, 5) in60Days := now.AddDate(0, 0, 60) daysThreshold := 30 tests := []struct { name string task *models.Task expected categorization.KanbanColumn }{ // Priority 1: Cancelled { name: "cancelled takes priority over everything", task: &models.Task{ IsCancelled: true, NextDueDate: timePtr(yesterday), // Would be overdue InProgress: true, // Would be in progress Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, // Would be completed if NextDueDate was nil }, expected: categorization.ColumnCancelled, }, // Priority 2: Completed { name: "completed: NextDueDate nil with completions", task: &models.Task{ IsCancelled: false, IsArchived: false, NextDueDate: nil, DueDate: timePtr(yesterday), // Would be overdue if not completed Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, }, expected: categorization.ColumnCompleted, }, { name: "not completed when NextDueDate set (recurring task with completions)", task: &models.Task{ IsCancelled: false, IsArchived: false, NextDueDate: timePtr(in5Days), Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, }, expected: categorization.ColumnDueSoon, // Falls through to due soon }, // Priority 3: In Progress { name: "in progress takes priority over overdue", task: &models.Task{ IsCancelled: false, IsArchived: false, NextDueDate: timePtr(yesterday), // Would be overdue InProgress: true, Completions: []models.TaskCompletion{}, }, expected: categorization.ColumnInProgress, }, // Priority 4: Overdue { name: "overdue: effective date in past", task: &models.Task{ IsCancelled: false, IsArchived: false, NextDueDate: timePtr(yesterday), Completions: []models.TaskCompletion{}, }, expected: categorization.ColumnOverdue, }, { name: "overdue: uses DueDate when NextDueDate nil (no completions)", task: &models.Task{ IsCancelled: false, IsArchived: false, NextDueDate: nil, DueDate: timePtr(yesterday), Completions: []models.TaskCompletion{}, }, expected: categorization.ColumnOverdue, }, // Priority 5: Due Soon { name: "due soon: within threshold", task: &models.Task{ IsCancelled: false, IsArchived: false, NextDueDate: timePtr(in5Days), Completions: []models.TaskCompletion{}, }, expected: categorization.ColumnDueSoon, }, // Priority 6: Upcoming (default) { name: "upcoming: beyond threshold", task: &models.Task{ IsCancelled: false, IsArchived: false, NextDueDate: timePtr(in60Days), Completions: []models.TaskCompletion{}, }, expected: categorization.ColumnUpcoming, }, { name: "upcoming: no due date", task: &models.Task{ IsCancelled: false, IsArchived: false, NextDueDate: nil, DueDate: nil, Completions: []models.TaskCompletion{}, }, expected: categorization.ColumnUpcoming, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := categorization.CategorizeTask(tt.task, daysThreshold) if result != tt.expected { t.Errorf("CategorizeTask() = %v, expected %v", result, tt.expected) } }) } } func TestCategorizeTasksIntoColumns(t *testing.T) { now := time.Now().UTC() yesterday := now.AddDate(0, 0, -1) in5Days := now.AddDate(0, 0, 5) in60Days := now.AddDate(0, 0, 60) daysThreshold := 30 tasks := []models.Task{ {BaseModel: models.BaseModel{ID: 1}, IsCancelled: true}, // Cancelled {BaseModel: models.BaseModel{ID: 2}, NextDueDate: nil, Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}}, // Completed {BaseModel: models.BaseModel{ID: 3}, InProgress: true}, // In Progress {BaseModel: models.BaseModel{ID: 4}, NextDueDate: timePtr(yesterday)}, // Overdue {BaseModel: models.BaseModel{ID: 5}, NextDueDate: timePtr(in5Days)}, // Due Soon {BaseModel: models.BaseModel{ID: 6}, NextDueDate: timePtr(in60Days)}, // Upcoming {BaseModel: models.BaseModel{ID: 7}}, // Upcoming (no due date) } result := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold) // Check each column has the expected tasks if len(result[categorization.ColumnCancelled]) != 1 || result[categorization.ColumnCancelled][0].ID != 1 { t.Errorf("Expected task 1 in Cancelled column") } if len(result[categorization.ColumnCompleted]) != 1 || result[categorization.ColumnCompleted][0].ID != 2 { t.Errorf("Expected task 2 in Completed column") } if len(result[categorization.ColumnInProgress]) != 1 || result[categorization.ColumnInProgress][0].ID != 3 { t.Errorf("Expected task 3 in InProgress column") } if len(result[categorization.ColumnOverdue]) != 1 || result[categorization.ColumnOverdue][0].ID != 4 { t.Errorf("Expected task 4 in Overdue column") } if len(result[categorization.ColumnDueSoon]) != 1 || result[categorization.ColumnDueSoon][0].ID != 5 { t.Errorf("Expected task 5 in DueSoon column") } if len(result[categorization.ColumnUpcoming]) != 2 { t.Errorf("Expected 2 tasks in Upcoming column, got %d", len(result[categorization.ColumnUpcoming])) } } func TestDetermineKanbanColumn_ReturnsString(t *testing.T) { task := &models.Task{IsCancelled: true} result := categorization.DetermineKanbanColumn(task, 30) if result != "cancelled_tasks" { t.Errorf("DetermineKanbanColumn() = %v, expected %v", result, "cancelled_tasks") } } func TestKanbanColumnConstants(t *testing.T) { // Verify column string values match expected API values tests := []struct { column categorization.KanbanColumn expected string }{ {categorization.ColumnOverdue, "overdue_tasks"}, {categorization.ColumnDueSoon, "due_soon_tasks"}, {categorization.ColumnUpcoming, "upcoming_tasks"}, {categorization.ColumnInProgress, "in_progress_tasks"}, {categorization.ColumnCompleted, "completed_tasks"}, {categorization.ColumnCancelled, "cancelled_tasks"}, } for _, tt := range tests { if tt.column.String() != tt.expected { t.Errorf("Column %v.String() = %v, expected %v", tt.column, tt.column.String(), tt.expected) } } } func TestNewContext_DefaultThreshold(t *testing.T) { task := &models.Task{} // Zero threshold should default to 30 ctx := categorization.NewContext(task, 0) if ctx.DaysThreshold != 30 { t.Errorf("NewContext with 0 threshold should default to 30, got %d", ctx.DaysThreshold) } // Negative threshold should default to 30 ctx = categorization.NewContext(task, -5) if ctx.DaysThreshold != 30 { t.Errorf("NewContext with negative threshold should default to 30, got %d", ctx.DaysThreshold) } // Positive threshold should be used ctx = categorization.NewContext(task, 45) if ctx.DaysThreshold != 45 { 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") } }