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

@@ -104,13 +104,18 @@ func ScopeNotInProgress(db *gorm.DB) *gorm.DB {
//
// Predicate equivalent: IsOverdue(task, now)
//
// SQL: COALESCE(next_due_date, due_date) < ? AND active AND not_completed
// IMPORTANT: Due dates are stored as midnight UTC but represent calendar dates.
// We compare dates (YYYY-MM-DD), not timestamps, to ensure timezone correctness.
// A task due "2024-12-16" should only be overdue when the user's date is "2024-12-17" or later.
//
// Uses DATE() function which works in both PostgreSQL and SQLite.
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())
// Extract user's current date as a string for date-only comparison
// This ensures timezone correctness: the user's "today" determines overdue status
todayStr := now.Format("2006-01-02")
return db.Scopes(ScopeActive, ScopeNotCompleted).
Where("COALESCE(next_due_date, due_date) < ?", startOfDay)
Where("DATE(COALESCE(next_due_date, due_date)) < DATE(?)", todayStr)
}
}
@@ -123,17 +128,16 @@ func ScopeOverdue(now time.Time) func(db *gorm.DB) *gorm.DB {
//
// Predicate equivalent: IsDueSoon(task, now, daysThreshold)
//
// SQL: COALESCE(next_due_date, due_date) >= ? AND COALESCE(next_due_date, due_date) < ?
//
// AND active AND not_completed
// IMPORTANT: Uses date comparison (not timestamps) for timezone correctness.
func ScopeDueSoon(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
// 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)
// Extract dates as strings for date-only comparison
todayStr := now.Format("2006-01-02")
thresholdDate := now.AddDate(0, 0, daysThreshold)
thresholdStr := thresholdDate.Format("2006-01-02")
return db.Scopes(ScopeActive, ScopeNotCompleted).
Where("COALESCE(next_due_date, due_date) >= ?", startOfDay).
Where("COALESCE(next_due_date, due_date) < ?", threshold)
Where("DATE(COALESCE(next_due_date, due_date)) >= DATE(?)", todayStr).
Where("DATE(COALESCE(next_due_date, due_date)) < DATE(?)", thresholdStr)
}
}
@@ -142,34 +146,32 @@ func ScopeDueSoon(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB {
// 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) >= ? OR (next_due_date IS NULL AND due_date IS NULL))
//
// AND active AND not_completed
// IMPORTANT: Uses date comparison (not timestamps) for timezone correctness.
func ScopeUpcoming(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
// 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)
// Compute threshold date
thresholdDate := now.AddDate(0, 0, daysThreshold)
thresholdStr := thresholdDate.Format("2006-01-02")
return db.Scopes(ScopeActive, ScopeNotCompleted).
Where(
"COALESCE(next_due_date, due_date) >= ? OR (next_due_date IS NULL AND due_date IS NULL)",
threshold,
"DATE(COALESCE(next_due_date, due_date)) >= DATE(?) OR (next_due_date IS NULL AND due_date IS NULL)",
thresholdStr,
)
}
}
// ScopeDueInRange returns a scope for tasks with effective date in a range.
//
// SQL: COALESCE(next_due_date, due_date) >= ? AND COALESCE(next_due_date, due_date) < ?
// IMPORTANT: Uses date comparison (not timestamps) for timezone correctness.
func ScopeDueInRange(start, end time.Time) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
startStr := start.Format("2006-01-02")
endStr := end.Format("2006-01-02")
return db.
Where("COALESCE(next_due_date, due_date) >= ?", start).
Where("COALESCE(next_due_date, due_date) < ?", end)
Where("DATE(COALESCE(next_due_date, due_date)) >= DATE(?)", startStr).
Where("DATE(COALESCE(next_due_date, due_date)) < DATE(?)", endStr)
}
}