Fix timezone bug in task kanban categorization
Task creation/update responses were using UTC time for kanban column categorization, causing tasks to incorrectly appear as overdue when the server had passed midnight UTC but the user's local time was still the previous day. Changes: - Add timezone-aware response functions (NewTaskResponseWithTime, etc.) - Pass userNow from middleware to all task service methods - Update handlers to use timezone-aware time from X-Timezone header - Update tests to pass the now parameter 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -149,8 +149,9 @@ func (s *TaskService) GetTasksByResidence(residenceID, userID uint, daysThreshol
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// CreateTask creates a new task
|
||||
func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) (*responses.TaskWithSummaryResponse, error) {
|
||||
// CreateTask creates a new task.
|
||||
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
||||
func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
||||
// Check residence access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID)
|
||||
if err != nil {
|
||||
@@ -189,13 +190,14 @@ func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) (
|
||||
}
|
||||
|
||||
return &responses.TaskWithSummaryResponse{
|
||||
Data: responses.NewTaskResponse(task),
|
||||
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||
Summary: s.getSummaryForUser(userID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateTask updates a task
|
||||
func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRequest) (*responses.TaskWithSummaryResponse, error) {
|
||||
// UpdateTask updates a task.
|
||||
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
||||
func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRequest, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
||||
task, err := s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -268,7 +270,7 @@ func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRe
|
||||
}
|
||||
|
||||
return &responses.TaskWithSummaryResponse{
|
||||
Data: responses.NewTaskResponse(task),
|
||||
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||
Summary: s.getSummaryForUser(userID),
|
||||
}, nil
|
||||
}
|
||||
@@ -304,8 +306,9 @@ func (s *TaskService) DeleteTask(taskID, userID uint) (*responses.DeleteWithSumm
|
||||
|
||||
// === Task Actions ===
|
||||
|
||||
// MarkInProgress marks a task as in progress
|
||||
func (s *TaskService) MarkInProgress(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) {
|
||||
// MarkInProgress marks a task as in progress.
|
||||
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
||||
func (s *TaskService) MarkInProgress(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
||||
task, err := s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -334,13 +337,14 @@ func (s *TaskService) MarkInProgress(taskID, userID uint) (*responses.TaskWithSu
|
||||
}
|
||||
|
||||
return &responses.TaskWithSummaryResponse{
|
||||
Data: responses.NewTaskResponse(task),
|
||||
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||
Summary: s.getSummaryForUser(userID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelTask cancels a task
|
||||
func (s *TaskService) CancelTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) {
|
||||
// CancelTask cancels a task.
|
||||
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
||||
func (s *TaskService) CancelTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
||||
task, err := s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -373,13 +377,14 @@ func (s *TaskService) CancelTask(taskID, userID uint) (*responses.TaskWithSummar
|
||||
}
|
||||
|
||||
return &responses.TaskWithSummaryResponse{
|
||||
Data: responses.NewTaskResponse(task),
|
||||
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||
Summary: s.getSummaryForUser(userID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UncancelTask uncancels a task
|
||||
func (s *TaskService) UncancelTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) {
|
||||
// UncancelTask uncancels a task.
|
||||
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
||||
func (s *TaskService) UncancelTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
||||
task, err := s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -408,13 +413,14 @@ func (s *TaskService) UncancelTask(taskID, userID uint) (*responses.TaskWithSumm
|
||||
}
|
||||
|
||||
return &responses.TaskWithSummaryResponse{
|
||||
Data: responses.NewTaskResponse(task),
|
||||
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||
Summary: s.getSummaryForUser(userID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ArchiveTask archives a task
|
||||
func (s *TaskService) ArchiveTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) {
|
||||
// ArchiveTask archives a task.
|
||||
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
||||
func (s *TaskService) ArchiveTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
||||
task, err := s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -447,13 +453,14 @@ func (s *TaskService) ArchiveTask(taskID, userID uint) (*responses.TaskWithSumma
|
||||
}
|
||||
|
||||
return &responses.TaskWithSummaryResponse{
|
||||
Data: responses.NewTaskResponse(task),
|
||||
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||
Summary: s.getSummaryForUser(userID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UnarchiveTask unarchives a task
|
||||
func (s *TaskService) UnarchiveTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) {
|
||||
// UnarchiveTask unarchives a task.
|
||||
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
||||
func (s *TaskService) UnarchiveTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
||||
task, err := s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -482,15 +489,16 @@ func (s *TaskService) UnarchiveTask(taskID, userID uint) (*responses.TaskWithSum
|
||||
}
|
||||
|
||||
return &responses.TaskWithSummaryResponse{
|
||||
Data: responses.NewTaskResponse(task),
|
||||
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||
Summary: s.getSummaryForUser(userID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// === Task Completions ===
|
||||
|
||||
// CreateCompletion creates a task completion
|
||||
func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest, userID uint) (*responses.TaskCompletionWithSummaryResponse, error) {
|
||||
// CreateCompletion creates a task completion.
|
||||
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
||||
func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest, userID uint, now time.Time) (*responses.TaskCompletionWithSummaryResponse, error) {
|
||||
// Get the task
|
||||
task, err := s.taskRepo.FindByID(req.TaskID)
|
||||
if err != nil {
|
||||
@@ -600,7 +608,7 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
|
||||
s.sendTaskCompletedNotification(task, completion)
|
||||
|
||||
// Return completion with updated task (includes kanban_column for UI update)
|
||||
resp := responses.NewTaskCompletionWithTaskResponse(completion, task, 30)
|
||||
resp := responses.NewTaskCompletionWithTaskResponseWithTime(completion, task, 30, now)
|
||||
return &responses.TaskCompletionWithSummaryResponse{
|
||||
Data: resp,
|
||||
Summary: s.getSummaryForUser(userID),
|
||||
|
||||
@@ -39,7 +39,8 @@ func TestTaskService_CreateTask(t *testing.T) {
|
||||
Description: "Kitchen faucet is dripping",
|
||||
}
|
||||
|
||||
resp, err := service.CreateTask(req, user.ID)
|
||||
now := time.Now().UTC()
|
||||
resp, err := service.CreateTask(req, user.ID, now)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, resp.Data.ID)
|
||||
assert.Equal(t, "Fix leaky faucet", resp.Data.Title)
|
||||
@@ -74,7 +75,8 @@ func TestTaskService_CreateTask_WithOptionalFields(t *testing.T) {
|
||||
EstimatedCost: &cost,
|
||||
}
|
||||
|
||||
resp, err := service.CreateTask(req, user.ID)
|
||||
now := time.Now().UTC()
|
||||
resp, err := service.CreateTask(req, user.ID, now)
|
||||
require.NoError(t, err)
|
||||
// Note: Category and Priority are no longer preloaded for performance
|
||||
// Client resolves from cache using CategoryID and PriorityID
|
||||
@@ -100,7 +102,8 @@ func TestTaskService_CreateTask_AccessDenied(t *testing.T) {
|
||||
Title: "Test Task",
|
||||
}
|
||||
|
||||
_, err := service.CreateTask(req, otherUser.ID)
|
||||
now := time.Now().UTC()
|
||||
_, err := service.CreateTask(req, otherUser.ID, now)
|
||||
// When creating a task, residence access is checked first
|
||||
assert.ErrorIs(t, err, ErrResidenceAccessDenied)
|
||||
}
|
||||
@@ -180,7 +183,8 @@ func TestTaskService_UpdateTask(t *testing.T) {
|
||||
Description: &newDesc,
|
||||
}
|
||||
|
||||
resp, err := service.UpdateTask(task.ID, user.ID, req)
|
||||
now := time.Now().UTC()
|
||||
resp, err := service.UpdateTask(task.ID, user.ID, req, now)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Updated Title", resp.Data.Title)
|
||||
assert.Equal(t, "Updated description", resp.Data.Description)
|
||||
@@ -215,7 +219,8 @@ func TestTaskService_CancelTask(t *testing.T) {
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
|
||||
|
||||
resp, err := service.CancelTask(task.ID, user.ID)
|
||||
now := time.Now().UTC()
|
||||
resp, err := service.CancelTask(task.ID, user.ID, now)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Data.IsCancelled)
|
||||
}
|
||||
@@ -231,8 +236,9 @@ func TestTaskService_CancelTask_AlreadyCancelled(t *testing.T) {
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
|
||||
|
||||
service.CancelTask(task.ID, user.ID)
|
||||
_, err := service.CancelTask(task.ID, user.ID)
|
||||
now := time.Now().UTC()
|
||||
service.CancelTask(task.ID, user.ID, now)
|
||||
_, err := service.CancelTask(task.ID, user.ID, now)
|
||||
assert.ErrorIs(t, err, ErrTaskAlreadyCancelled)
|
||||
}
|
||||
|
||||
@@ -247,8 +253,9 @@ func TestTaskService_UncancelTask(t *testing.T) {
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
|
||||
|
||||
service.CancelTask(task.ID, user.ID)
|
||||
resp, err := service.UncancelTask(task.ID, user.ID)
|
||||
now := time.Now().UTC()
|
||||
service.CancelTask(task.ID, user.ID, now)
|
||||
resp, err := service.UncancelTask(task.ID, user.ID, now)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.Data.IsCancelled)
|
||||
}
|
||||
@@ -264,7 +271,8 @@ func TestTaskService_ArchiveTask(t *testing.T) {
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
|
||||
|
||||
resp, err := service.ArchiveTask(task.ID, user.ID)
|
||||
now := time.Now().UTC()
|
||||
resp, err := service.ArchiveTask(task.ID, user.ID, now)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Data.IsArchived)
|
||||
}
|
||||
@@ -280,8 +288,9 @@ func TestTaskService_UnarchiveTask(t *testing.T) {
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
|
||||
|
||||
service.ArchiveTask(task.ID, user.ID)
|
||||
resp, err := service.UnarchiveTask(task.ID, user.ID)
|
||||
now := time.Now().UTC()
|
||||
service.ArchiveTask(task.ID, user.ID, now)
|
||||
resp, err := service.UnarchiveTask(task.ID, user.ID, now)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.Data.IsArchived)
|
||||
}
|
||||
@@ -297,7 +306,8 @@ func TestTaskService_MarkInProgress(t *testing.T) {
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
|
||||
|
||||
resp, err := service.MarkInProgress(task.ID, user.ID)
|
||||
now := time.Now().UTC()
|
||||
resp, err := service.MarkInProgress(task.ID, user.ID, now)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Data.InProgress)
|
||||
}
|
||||
@@ -318,7 +328,8 @@ func TestTaskService_CreateCompletion(t *testing.T) {
|
||||
Notes: "Completed successfully",
|
||||
}
|
||||
|
||||
resp, err := service.CreateCompletion(req, user.ID)
|
||||
now := time.Now().UTC()
|
||||
resp, err := service.CreateCompletion(req, user.ID, now)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, resp.Data.ID)
|
||||
assert.Equal(t, task.ID, resp.Data.TaskID)
|
||||
@@ -360,7 +371,8 @@ func TestTaskService_CreateCompletion_RecurringTask_ResetsInProgress(t *testing.
|
||||
Notes: "Monthly maintenance done",
|
||||
}
|
||||
|
||||
resp, err := service.CreateCompletion(req, user.ID)
|
||||
now := time.Now().UTC()
|
||||
resp, err := service.CreateCompletion(req, user.ID, now)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, resp.Data.ID)
|
||||
|
||||
@@ -503,6 +515,7 @@ func TestTaskService_SharedUserAccess(t *testing.T) {
|
||||
ResidenceID: residence.ID,
|
||||
Title: "Shared User Task",
|
||||
}
|
||||
_, err = service.CreateTask(req, sharedUser.ID)
|
||||
now := time.Now().UTC()
|
||||
_, err = service.CreateTask(req, sharedUser.ID, now)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user