Fix date comparison for cross-DB compatibility and add timezone coverage

- Change all date scopes from PostgreSQL-specific ::date to DATE() function
  which works in both PostgreSQL and SQLite (used in tests)
- Fix ScopeOverdue, ScopeDueSoon, ScopeUpcoming, ScopeDueInRange
- Fix GetOverdueTasks inline query in task_repo.go

- Fix timezone unit tests: due dates must be stored as midnight UTC
  (calendar dates), not with timezone info that GORM converts to UTC
- Update TestGetOverdueTasks_Timezone_Tokyo, NewYork, InternationalDateLine
- Update TestGetDueSoonTasks_Timezone_DST

- Add TestIntegration_TimezoneDivergence: proves same task appears in
  different kanban columns based on X-Timezone header
- Update TestIntegration_DateBoundaryEdgeCases to use America/New_York
- Update TestIntegration_TasksByResidenceKanban to use America/Los_Angeles
- Add identity-based column membership assertions (columnTaskIDs approach)

🤖 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-16 14:59:12 -06:00
parent 6dac34e373
commit 9bd0708ca4
4 changed files with 508 additions and 111 deletions

View File

@@ -110,10 +110,11 @@ func (r *TaskRepository) GetOverdueTasks(now time.Time, opts TaskFilterOptions)
if opts.IncludeArchived {
// When including archived, build the query manually to skip the archived check
// but still apply cancelled check, not-completed check, and date check
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
// IMPORTANT: Use date comparison (not timestamps) for timezone correctness
todayStr := now.Format("2006-01-02")
query = query.Where("is_cancelled = ?", false).
Scopes(task.ScopeNotCompleted).
Where("COALESCE(next_due_date, due_date) < ?", startOfDay)
Where("DATE(COALESCE(next_due_date, due_date)) < DATE(?)", todayStr)
} else {
// Use the combined scope which includes is_archived = false
query = query.Scopes(task.ScopeOverdue(now))

View File

@@ -1493,9 +1493,10 @@ func TestGetOverdueTasks_Timezone_Tokyo(t *testing.T) {
tokyo, _ := time.LoadLocation("Asia/Tokyo")
// Task due on Dec 15 at midnight in Tokyo timezone
// When stored as UTC, this becomes Dec 14 15:00 UTC
dueDate := time.Date(2025, 12, 15, 0, 0, 0, 0, tokyo)
// IMPORTANT: Due dates are stored as midnight UTC representing calendar dates.
// A task "due on Dec 15" is stored as 2025-12-15 00:00:00 UTC.
// Timezone handling happens at query time via the `now` parameter.
dueDate := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
@@ -1506,13 +1507,14 @@ func TestGetOverdueTasks_Timezone_Tokyo(t *testing.T) {
opts := TaskFilterOptions{ResidenceID: residence.ID}
// When it's Dec 15 23:00 in Tokyo, task should NOT be overdue (still Dec 15)
// When it's Dec 15 23:00 in Tokyo, task should NOT be overdue (still Dec 15 in Tokyo)
// The `now` parameter's date (Dec 15) is compared against the due date (Dec 15)
tokyoDec15Evening := time.Date(2025, 12, 15, 23, 0, 0, 0, tokyo)
tasks, err := repo.GetOverdueTasks(tokyoDec15Evening, opts)
require.NoError(t, err)
assert.Len(t, tasks, 0, "Task should NOT be overdue on Dec 15 in Tokyo timezone")
// When it's Dec 16 00:00 in Tokyo, task IS overdue (now Dec 16)
// When it's Dec 16 00:00 in Tokyo, task IS overdue (now Dec 16 in Tokyo, due Dec 15)
tokyoDec16Midnight := time.Date(2025, 12, 16, 0, 0, 0, 0, tokyo)
tasks, err = repo.GetOverdueTasks(tokyoDec16Midnight, opts)
require.NoError(t, err)
@@ -1528,9 +1530,10 @@ func TestGetOverdueTasks_Timezone_NewYork(t *testing.T) {
newYork, _ := time.LoadLocation("America/New_York")
// Task due on Dec 15 at midnight in New York timezone
// When stored as UTC, this becomes Dec 15 05:00 UTC (EST is UTC-5)
dueDate := time.Date(2025, 12, 15, 0, 0, 0, 0, newYork)
// IMPORTANT: Due dates are stored as midnight UTC representing calendar dates.
// A task "due on Dec 15" is stored as 2025-12-15 00:00:00 UTC.
// Timezone handling happens at query time via the `now` parameter.
dueDate := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
@@ -1541,13 +1544,13 @@ func TestGetOverdueTasks_Timezone_NewYork(t *testing.T) {
opts := TaskFilterOptions{ResidenceID: residence.ID}
// When it's Dec 15 23:00 in New York, task should NOT be overdue
// When it's Dec 15 23:00 in New York, task should NOT be overdue (still Dec 15 in NY)
nyDec15Evening := time.Date(2025, 12, 15, 23, 0, 0, 0, newYork)
tasks, err := repo.GetOverdueTasks(nyDec15Evening, opts)
require.NoError(t, err)
assert.Len(t, tasks, 0, "Task should NOT be overdue on Dec 15 in New York timezone")
// When it's Dec 16 00:00 in New York, task IS overdue
// When it's Dec 16 00:00 in New York, task IS overdue (now Dec 16 in NY, due Dec 15)
nyDec16Midnight := time.Date(2025, 12, 16, 0, 0, 0, 0, newYork)
tasks, err = repo.GetOverdueTasks(nyDec16Midnight, opts)
require.NoError(t, err)
@@ -1566,8 +1569,9 @@ func TestGetOverdueTasks_Timezone_InternationalDateLine(t *testing.T) {
auckland, _ := time.LoadLocation("Pacific/Auckland")
honolulu, _ := time.LoadLocation("Pacific/Honolulu")
// Task due on Dec 15 at midnight Auckland time
dueDate := time.Date(2025, 12, 15, 0, 0, 0, 0, auckland)
// IMPORTANT: Due dates are stored as midnight UTC representing calendar dates.
// Task due on Dec 15 is stored as 2025-12-15 00:00:00 UTC.
dueDate := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
@@ -1578,14 +1582,13 @@ func TestGetOverdueTasks_Timezone_InternationalDateLine(t *testing.T) {
opts := TaskFilterOptions{ResidenceID: residence.ID}
// From Auckland's perspective on Dec 16, task is overdue
// From Auckland's perspective on Dec 16, task is overdue (Dec 16 > Dec 15)
aucklandDec16 := time.Date(2025, 12, 16, 0, 0, 0, 0, auckland)
tasks, err := repo.GetOverdueTasks(aucklandDec16, opts)
require.NoError(t, err)
assert.Len(t, tasks, 1, "Task should be overdue on Dec 16 in Auckland")
// From Honolulu's perspective on Dec 14 (same UTC instant as Auckland Dec 15 morning),
// it's still before the due date, so NOT overdue
// From Honolulu's perspective on Dec 14, task is NOT overdue (Dec 14 < Dec 15)
honoluluDec14 := time.Date(2025, 12, 14, 5, 0, 0, 0, honolulu)
tasks, err = repo.GetOverdueTasks(honoluluDec14, opts)
require.NoError(t, err)
@@ -1601,9 +1604,10 @@ func TestGetDueSoonTasks_Timezone_DST(t *testing.T) {
newYork, _ := time.LoadLocation("America/New_York")
// IMPORTANT: Due dates are stored as midnight UTC representing calendar dates.
// 2025 DST ends Nov 2: clocks fall back from 2:00 AM to 1:00 AM
// Task due on Nov 5 at midnight in New York timezone
dueDate := time.Date(2025, 11, 5, 0, 0, 0, 0, newYork)
// Task due on Nov 5 is stored as 2025-11-05 00:00:00 UTC
dueDate := time.Date(2025, 11, 5, 0, 0, 0, 0, time.UTC)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,