From 684856e0e9918e3fef99701c49308facc1ecb98f Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 13 Dec 2025 00:04:09 -0600 Subject: [PATCH] Add timezone-aware overdue task detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix issue where tasks showed as "Overdue" on the server while displaying "Tomorrow" on the client due to timezone differences between server (UTC) and user's local timezone. Changes: - Add X-Timezone header support to extract user's timezone from requests - Add TimezoneMiddleware to parse timezone and calculate user's local "today" - Update task categorization to accept custom time for accurate date comparisons - Update repository, service, and handler layers to pass timezone-aware time - Update CORS to allow X-Timezone header The client now sends the user's IANA timezone (e.g., "America/Los_Angeles") and the server uses it to determine if a task is overdue based on the user's local date, not UTC. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/handlers/residence_handler.go | 6 +- internal/handlers/task_handler.go | 8 ++- internal/middleware/timezone.go | 98 ++++++++++++++++++++++++++ internal/repositories/task_repo.go | 26 +++---- internal/router/router.go | 3 +- internal/services/residence_service.go | 29 ++++---- internal/services/task_service.go | 22 +++--- internal/task/categorization/chain.go | 59 ++++++++++++++-- 8 files changed, 206 insertions(+), 45 deletions(-) create mode 100644 internal/middleware/timezone.go diff --git a/internal/handlers/residence_handler.go b/internal/handlers/residence_handler.go index 9efb4f0..4f833d4 100644 --- a/internal/handlers/residence_handler.go +++ b/internal/handlers/residence_handler.go @@ -46,8 +46,9 @@ func (h *ResidenceHandler) ListResidences(c *gin.Context) { // GetMyResidences handles GET /api/residences/my-residences/ func (h *ResidenceHandler) GetMyResidences(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) + userNow := middleware.GetUserNow(c) - response, err := h.residenceService.GetMyResidences(user.ID) + response, err := h.residenceService.GetMyResidences(user.ID, userNow) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -60,8 +61,9 @@ func (h *ResidenceHandler) GetMyResidences(c *gin.Context) { // Returns just the task statistics summary without full residence data func (h *ResidenceHandler) GetSummary(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) + userNow := middleware.GetUserNow(c) - summary, err := h.residenceService.GetSummary(user.ID) + summary, err := h.residenceService.GetSummary(user.ID, userNow) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/internal/handlers/task_handler.go b/internal/handlers/task_handler.go index d4efa37..d4cb920 100644 --- a/internal/handlers/task_handler.go +++ b/internal/handlers/task_handler.go @@ -35,7 +35,9 @@ func NewTaskHandler(taskService *services.TaskService, storageService *services. // ListTasks handles GET /api/tasks/ func (h *TaskHandler) ListTasks(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) - response, err := h.taskService.ListTasks(user.ID) + userNow := middleware.GetUserNow(c) + + response, err := h.taskService.ListTasks(user.ID, userNow) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -70,6 +72,8 @@ func (h *TaskHandler) GetTask(c *gin.Context) { // GetTasksByResidence handles GET /api/tasks/by-residence/:residence_id/ func (h *TaskHandler) GetTasksByResidence(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) + userNow := middleware.GetUserNow(c) + residenceID, err := strconv.ParseUint(c.Param("residence_id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")}) @@ -83,7 +87,7 @@ func (h *TaskHandler) GetTasksByResidence(c *gin.Context) { } } - response, err := h.taskService.GetTasksByResidence(uint(residenceID), user.ID, daysThreshold) + response, err := h.taskService.GetTasksByResidence(uint(residenceID), user.ID, daysThreshold, userNow) if err != nil { switch { case errors.Is(err, services.ErrResidenceAccessDenied): diff --git a/internal/middleware/timezone.go b/internal/middleware/timezone.go new file mode 100644 index 0000000..9471359 --- /dev/null +++ b/internal/middleware/timezone.go @@ -0,0 +1,98 @@ +package middleware + +import ( + "time" + + "github.com/gin-gonic/gin" +) + +const ( + // TimezoneKey is the key used to store the user's timezone in the context + TimezoneKey = "user_timezone" + // UserNowKey is the key used to store the timezone-aware "now" time in the context + UserNowKey = "user_now" + // TimezoneHeader is the HTTP header name for the user's timezone + TimezoneHeader = "X-Timezone" +) + +// TimezoneMiddleware extracts the user's timezone from the request header +// and stores a timezone-aware "now" time in the context. +// +// The timezone should be an IANA timezone name (e.g., "America/Los_Angeles", "Europe/London") +// or a UTC offset (e.g., "-08:00", "+05:30"). +// +// If no timezone is provided or it's invalid, UTC is used as the default. +func TimezoneMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + tzName := c.GetHeader(TimezoneHeader) + loc := parseTimezone(tzName) + + // Store the location and the current time in that timezone + c.Set(TimezoneKey, loc) + + // Calculate "now" in the user's timezone, then get start of day + // For date comparisons, we want to compare against the START of the user's current day + userNow := time.Now().In(loc) + startOfDay := time.Date(userNow.Year(), userNow.Month(), userNow.Day(), 0, 0, 0, 0, loc) + c.Set(UserNowKey, startOfDay) + + c.Next() + } +} + +// parseTimezone parses a timezone string and returns a *time.Location. +// Supports IANA timezone names (e.g., "America/Los_Angeles") and +// UTC offsets (e.g., "-08:00", "+05:30"). +// Returns UTC if the timezone is invalid or empty. +func parseTimezone(tz string) *time.Location { + if tz == "" { + return time.UTC + } + + // Try parsing as IANA timezone name first + loc, err := time.LoadLocation(tz) + if err == nil { + return loc + } + + // Try parsing as UTC offset (e.g., "-08:00", "+05:30") + // We parse a reference time with the given offset to extract the offset value + t, err := time.Parse("-07:00", tz) + if err == nil { + // time.Parse returns a time, we need to extract the offset + // The parsed time will have the offset embedded + _, offset := t.Zone() + return time.FixedZone(tz, offset) + } + + // Also try without colon (e.g., "-0800") + t, err = time.Parse("-0700", tz) + if err == nil { + _, offset := t.Zone() + return time.FixedZone(tz, offset) + } + + // Default to UTC + return time.UTC +} + +// GetUserTimezone retrieves the user's timezone from the Gin context. +// Returns UTC if not set. +func GetUserTimezone(c *gin.Context) *time.Location { + loc, exists := c.Get(TimezoneKey) + if !exists { + return time.UTC + } + return loc.(*time.Location) +} + +// GetUserNow retrieves the timezone-aware "now" time from the Gin context. +// This represents the start of the current day in the user's timezone. +// Returns time.Now().UTC() if not set. +func GetUserNow(c *gin.Context) time.Time { + now, exists := c.Get(UserNowKey) + if !exists { + return time.Now().UTC() + } + return now.(time.Time) +} diff --git a/internal/repositories/task_repo.go b/internal/repositories/task_repo.go index 185fbdf..0de91bb 100644 --- a/internal/repositories/task_repo.go +++ b/internal/repositories/task_repo.go @@ -131,9 +131,10 @@ func (r *TaskRepository) Unarchive(id uint) error { // === Kanban Board === -// GetKanbanData retrieves tasks organized for kanban display +// GetKanbanData retrieves tasks organized for kanban display. // Uses the task.categorization package as the single source of truth for categorization logic. -func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*models.KanbanBoard, error) { +// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection. +func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) { var tasks []models.Task err := r.db.Preload("CreatedBy"). Preload("AssignedTo"). @@ -151,7 +152,8 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo } // Use the categorization package as the single source of truth - categorized := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold) + // Pass the user's timezone-aware time for accurate overdue detection + categorized := categorization.CategorizeTasksIntoColumnsWithTime(tasks, daysThreshold, now) columns := []models.KanbanColumn{ { @@ -217,9 +219,10 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo }, nil } -// GetKanbanDataForMultipleResidences retrieves tasks from multiple residences organized for kanban display +// GetKanbanDataForMultipleResidences retrieves tasks from multiple residences organized for kanban display. // Uses the task.categorization package as the single source of truth for categorization logic. -func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, daysThreshold int) (*models.KanbanBoard, error) { +// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection. +func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) { var tasks []models.Task err := r.db.Preload("CreatedBy"). Preload("AssignedTo"). @@ -238,7 +241,8 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, } // Use the categorization package as the single source of truth - categorized := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold) + // Pass the user's timezone-aware time for accurate overdue detection + categorized := categorization.CategorizeTasksIntoColumnsWithTime(tasks, daysThreshold, now) columns := []models.KanbanColumn{ { @@ -418,13 +422,12 @@ type TaskStatistics struct { // GetTaskStatistics returns aggregated task statistics for multiple residences. // Uses the task.scopes package for consistent filtering logic. -func (r *TaskRepository) GetTaskStatistics(residenceIDs []uint) (*TaskStatistics, error) { +// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection. +func (r *TaskRepository) GetTaskStatistics(residenceIDs []uint, now time.Time) (*TaskStatistics, error) { if len(residenceIDs) == 0 { return &TaskStatistics{}, nil } - now := time.Now().UTC() - var totalTasks, totalOverdue, totalPending, tasksDueNextWeek, tasksDueNextMonth int64 // Count total active tasks (not cancelled, not archived) @@ -483,13 +486,12 @@ func (r *TaskRepository) GetTaskStatistics(residenceIDs []uint) (*TaskStatistics // GetOverdueCountByResidence returns a map of residence ID to overdue task count. // Uses the task.scopes package for consistent filtering logic. -func (r *TaskRepository) GetOverdueCountByResidence(residenceIDs []uint) (map[uint]int, error) { +// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection. +func (r *TaskRepository) GetOverdueCountByResidence(residenceIDs []uint, now time.Time) (map[uint]int, error) { if len(residenceIDs) == 0 { return map[uint]int{}, nil } - now := time.Now().UTC() - // Query to get overdue count grouped by residence type result struct { ResidenceID uint diff --git a/internal/router/router.go b/internal/router/router.go index b520fb0..bf90f60 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -172,6 +172,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine { // Protected routes (auth required) protected := api.Group("") protected.Use(authMiddleware.TokenAuth()) + protected.Use(middleware.TimezoneMiddleware()) { setupProtectedAuthRoutes(protected, authHandler) setupResidenceRoutes(protected, residenceHandler) @@ -202,7 +203,7 @@ func corsMiddleware(cfg *config.Config) gin.HandlerFunc { return cors.New(cors.Config{ AllowAllOrigins: true, AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, - AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With", "X-Timezone"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: false, // Must be false when AllowAllOrigins is true MaxAge: 12 * time.Hour, diff --git a/internal/services/residence_service.go b/internal/services/residence_service.go index 5ef9978..24c0908 100644 --- a/internal/services/residence_service.go +++ b/internal/services/residence_service.go @@ -82,8 +82,9 @@ func (s *ResidenceService) ListResidences(userID uint) ([]responses.ResidenceRes } // GetMyResidences returns residences with additional details (tasks, completions, etc.) -// This is the "my-residences" endpoint that returns richer data -func (s *ResidenceService) GetMyResidences(userID uint) (*responses.MyResidencesResponse, error) { +// This is the "my-residences" endpoint that returns richer data. +// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection. +func (s *ResidenceService) GetMyResidences(userID uint, now time.Time) (*responses.MyResidencesResponse, error) { residences, err := s.residenceRepo.FindByUser(userID) if err != nil { return nil, err @@ -104,8 +105,8 @@ func (s *ResidenceService) GetMyResidences(userID uint) (*responses.MyResidences residenceIDs[i] = r.ID } - // Get aggregated statistics - stats, err := s.taskRepo.GetTaskStatistics(residenceIDs) + // Get aggregated statistics using user's timezone-aware time + stats, err := s.taskRepo.GetTaskStatistics(residenceIDs, now) if err == nil && stats != nil { summary.TotalTasks = stats.TotalTasks summary.TotalPending = stats.TotalPending @@ -114,8 +115,8 @@ func (s *ResidenceService) GetMyResidences(userID uint) (*responses.MyResidences summary.TasksDueNextMonth = stats.TasksDueNextMonth } - // Get per-residence overdue counts - overdueCounts, err := s.taskRepo.GetOverdueCountByResidence(residenceIDs) + // Get per-residence overdue counts using user's timezone-aware time + overdueCounts, err := s.taskRepo.GetOverdueCountByResidence(residenceIDs, now) if err == nil && overdueCounts != nil { for i := range residenceResponses { if count, ok := overdueCounts[residenceResponses[i].ID]; ok { @@ -131,9 +132,10 @@ func (s *ResidenceService) GetMyResidences(userID uint) (*responses.MyResidences }, nil } -// GetSummary returns just the task summary statistics for a user's residences -// This is a lightweight endpoint for refreshing summary counts without full residence data -func (s *ResidenceService) GetSummary(userID uint) (*responses.TotalSummary, error) { +// GetSummary returns just the task summary statistics for a user's residences. +// This is a lightweight endpoint for refreshing summary counts without full residence data. +// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection. +func (s *ResidenceService) GetSummary(userID uint, now time.Time) (*responses.TotalSummary, error) { residences, err := s.residenceRepo.FindByUser(userID) if err != nil { return nil, err @@ -151,8 +153,8 @@ func (s *ResidenceService) GetSummary(userID uint) (*responses.TotalSummary, err residenceIDs[i] = r.ID } - // Get aggregated statistics - stats, err := s.taskRepo.GetTaskStatistics(residenceIDs) + // Get aggregated statistics using user's timezone-aware time + stats, err := s.taskRepo.GetTaskStatistics(residenceIDs, now) if err == nil && stats != nil { summary.TotalTasks = stats.TotalTasks summary.TotalPending = stats.TotalPending @@ -165,9 +167,10 @@ func (s *ResidenceService) GetSummary(userID uint) (*responses.TotalSummary, err return summary, nil } -// getSummaryForUser is a helper that returns summary for a user, or empty summary on error +// getSummaryForUser is a helper that returns summary for a user, or empty summary on error. +// Uses UTC time. For timezone-aware summary, use GetSummary directly. func (s *ResidenceService) getSummaryForUser(userID uint) responses.TotalSummary { - summary, err := s.GetSummary(userID) + summary, err := s.GetSummary(userID, time.Now().UTC()) if err != nil || summary == nil { return responses.TotalSummary{} } diff --git a/internal/services/task_service.go b/internal/services/task_service.go index b6e035c..373df01 100644 --- a/internal/services/task_service.go +++ b/internal/services/task_service.go @@ -55,12 +55,13 @@ func (s *TaskService) SetResidenceService(rs *ResidenceService) { s.residenceService = rs } -// getSummaryForUser gets the total summary for a user (helper for CRUD responses) +// getSummaryForUser gets the total summary for a user (helper for CRUD responses). +// Uses UTC time. For timezone-aware summary, call residence service directly. func (s *TaskService) getSummaryForUser(userID uint) responses.TotalSummary { if s.residenceService == nil { return responses.TotalSummary{} } - summary, err := s.residenceService.GetSummary(userID) + summary, err := s.residenceService.GetSummary(userID, time.Now().UTC()) if err != nil || summary == nil { return responses.TotalSummary{} } @@ -92,8 +93,9 @@ func (s *TaskService) GetTask(taskID, userID uint) (*responses.TaskResponse, err return &resp, nil } -// ListTasks lists all tasks accessible to a user as a kanban board -func (s *TaskService) ListTasks(userID uint) (*responses.KanbanBoardResponse, error) { +// ListTasks lists all tasks accessible to a user as a kanban board. +// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection. +func (s *TaskService) ListTasks(userID uint, now time.Time) (*responses.KanbanBoardResponse, error) { // Get all residence IDs accessible to user residences, err := s.residenceRepo.FindByUser(userID) if err != nil { @@ -114,8 +116,8 @@ func (s *TaskService) ListTasks(userID uint) (*responses.KanbanBoardResponse, er }, nil } - // Get kanban data aggregated across all residences - board, err := s.taskRepo.GetKanbanDataForMultipleResidences(residenceIDs, 30) + // Get kanban data aggregated across all residences using user's timezone-aware time + board, err := s.taskRepo.GetKanbanDataForMultipleResidences(residenceIDs, 30, now) if err != nil { return nil, err } @@ -127,8 +129,9 @@ func (s *TaskService) ListTasks(userID uint) (*responses.KanbanBoardResponse, er return &resp, nil } -// GetTasksByResidence gets tasks for a specific residence (kanban board) -func (s *TaskService) GetTasksByResidence(residenceID, userID uint, daysThreshold int) (*responses.KanbanBoardResponse, error) { +// GetTasksByResidence gets tasks for a specific residence (kanban board). +// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection. +func (s *TaskService) GetTasksByResidence(residenceID, userID uint, daysThreshold int, now time.Time) (*responses.KanbanBoardResponse, error) { // Check access hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID) if err != nil { @@ -142,7 +145,8 @@ func (s *TaskService) GetTasksByResidence(residenceID, userID uint, daysThreshol daysThreshold = 30 // Default } - board, err := s.taskRepo.GetKanbanData(residenceID, daysThreshold) + // Get kanban data using user's timezone-aware time + board, err := s.taskRepo.GetKanbanData(residenceID, daysThreshold, now) if err != nil { return nil, err } diff --git a/internal/task/categorization/chain.go b/internal/task/categorization/chain.go index bca5c60..9e0313f 100644 --- a/internal/task/categorization/chain.go +++ b/internal/task/categorization/chain.go @@ -40,7 +40,8 @@ type Context struct { DaysThreshold int } -// NewContext creates a new categorization context with sensible defaults +// NewContext creates a new categorization context with sensible defaults. +// Uses UTC time. For timezone-aware categorization, use NewContextWithTime. func NewContext(t *models.Task, daysThreshold int) *Context { if daysThreshold <= 0 { daysThreshold = 30 @@ -52,6 +53,20 @@ func NewContext(t *models.Task, daysThreshold int) *Context { } } +// 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. +func NewContextWithTime(t *models.Task, daysThreshold int, now time.Time) *Context { + if daysThreshold <= 0 { + daysThreshold = 30 + } + return &Context{ + Task: t, + Now: now, + DaysThreshold: daysThreshold, + } +} + // ThresholdDate returns the date threshold for "due soon" categorization func (c *Context) ThresholdDate() time.Time { return c.Now.AddDate(0, 0, c.DaysThreshold) @@ -207,12 +222,21 @@ func NewChain() *Chain { return &Chain{head: cancelled} } -// Categorize determines which kanban column a task belongs to +// Categorize determines which kanban column a task belongs to. +// Uses UTC time. For timezone-aware categorization, use CategorizeWithTime. func (c *Chain) Categorize(t *models.Task, daysThreshold int) KanbanColumn { ctx := NewContext(t, daysThreshold) return c.head.Handle(ctx) } +// CategorizeWithTime determines which kanban column a task belongs to using a specific time. +// Use this when you need timezone-aware categorization - pass the start of day +// in the user's timezone. +func (c *Chain) CategorizeWithTime(t *models.Task, daysThreshold int, now time.Time) KanbanColumn { + ctx := NewContextWithTime(t, daysThreshold, now) + return c.head.Handle(ctx) +} + // CategorizeWithContext uses a pre-built context for categorization func (c *Chain) CategorizeWithContext(ctx *Context) KanbanColumn { return c.head.Handle(ctx) @@ -223,18 +247,41 @@ func (c *Chain) CategorizeWithContext(ctx *Context) KanbanColumn { // defaultChain is a singleton chain instance for convenience var defaultChain = NewChain() -// DetermineKanbanColumn is a convenience function that uses the default chain +// DetermineKanbanColumn is a convenience function that uses the default chain. +// Uses UTC time. For timezone-aware categorization, use DetermineKanbanColumnWithTime. func DetermineKanbanColumn(t *models.Task, daysThreshold int) string { return defaultChain.Categorize(t, daysThreshold).String() } -// CategorizeTask is an alias for DetermineKanbanColumn with a more descriptive name +// DetermineKanbanColumnWithTime is a convenience function that uses a specific time. +// Use this when you need timezone-aware categorization. +func DetermineKanbanColumnWithTime(t *models.Task, daysThreshold int, now time.Time) string { + return defaultChain.CategorizeWithTime(t, daysThreshold, now).String() +} + +// CategorizeTask is an alias for DetermineKanbanColumn with a more descriptive name. +// Uses UTC time. For timezone-aware categorization, use CategorizeTaskWithTime. func CategorizeTask(t *models.Task, daysThreshold int) KanbanColumn { return defaultChain.Categorize(t, daysThreshold) } -// CategorizeTasksIntoColumns categorizes multiple tasks into their respective columns +// CategorizeTaskWithTime categorizes a task using a specific time. +// Use this when you need timezone-aware categorization - pass the start of day +// in the user's timezone. +func CategorizeTaskWithTime(t *models.Task, daysThreshold int, now time.Time) KanbanColumn { + return defaultChain.CategorizeWithTime(t, daysThreshold, now) +} + +// CategorizeTasksIntoColumns categorizes multiple tasks into their respective columns. +// Uses UTC time. For timezone-aware categorization, use CategorizeTasksIntoColumnsWithTime. func CategorizeTasksIntoColumns(tasks []models.Task, daysThreshold int) map[KanbanColumn][]models.Task { + return CategorizeTasksIntoColumnsWithTime(tasks, daysThreshold, time.Now().UTC()) +} + +// CategorizeTasksIntoColumnsWithTime categorizes multiple tasks using a specific time. +// Use this when you need timezone-aware categorization - pass the start of day +// in the user's timezone. +func CategorizeTasksIntoColumnsWithTime(tasks []models.Task, daysThreshold int, now time.Time) map[KanbanColumn][]models.Task { result := make(map[KanbanColumn][]models.Task) // Initialize all columns with empty slices @@ -248,7 +295,7 @@ func CategorizeTasksIntoColumns(tasks []models.Task, daysThreshold int) map[Kanb // Categorize each task chain := NewChain() for _, t := range tasks { - column := chain.Categorize(&t, daysThreshold) + column := chain.CategorizeWithTime(&t, daysThreshold, now) result[column] = append(result[column], t) }