Add timezone-aware overdue task detection
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 <noreply@anthropic.com>
This commit is contained in:
@@ -46,8 +46,9 @@ func (h *ResidenceHandler) ListResidences(c *gin.Context) {
|
|||||||
// GetMyResidences handles GET /api/residences/my-residences/
|
// GetMyResidences handles GET /api/residences/my-residences/
|
||||||
func (h *ResidenceHandler) GetMyResidences(c *gin.Context) {
|
func (h *ResidenceHandler) GetMyResidences(c *gin.Context) {
|
||||||
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -60,8 +61,9 @@ func (h *ResidenceHandler) GetMyResidences(c *gin.Context) {
|
|||||||
// Returns just the task statistics summary without full residence data
|
// Returns just the task statistics summary without full residence data
|
||||||
func (h *ResidenceHandler) GetSummary(c *gin.Context) {
|
func (h *ResidenceHandler) GetSummary(c *gin.Context) {
|
||||||
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ func NewTaskHandler(taskService *services.TaskService, storageService *services.
|
|||||||
// ListTasks handles GET /api/tasks/
|
// ListTasks handles GET /api/tasks/
|
||||||
func (h *TaskHandler) ListTasks(c *gin.Context) {
|
func (h *TaskHandler) ListTasks(c *gin.Context) {
|
||||||
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -70,6 +72,8 @@ func (h *TaskHandler) GetTask(c *gin.Context) {
|
|||||||
// GetTasksByResidence handles GET /api/tasks/by-residence/:residence_id/
|
// GetTasksByResidence handles GET /api/tasks/by-residence/:residence_id/
|
||||||
func (h *TaskHandler) GetTasksByResidence(c *gin.Context) {
|
func (h *TaskHandler) GetTasksByResidence(c *gin.Context) {
|
||||||
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
||||||
|
userNow := middleware.GetUserNow(c)
|
||||||
|
|
||||||
residenceID, err := strconv.ParseUint(c.Param("residence_id"), 10, 32)
|
residenceID, err := strconv.ParseUint(c.Param("residence_id"), 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
|
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 {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrResidenceAccessDenied):
|
case errors.Is(err, services.ErrResidenceAccessDenied):
|
||||||
|
|||||||
98
internal/middleware/timezone.go
Normal file
98
internal/middleware/timezone.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -131,9 +131,10 @@ func (r *TaskRepository) Unarchive(id uint) error {
|
|||||||
|
|
||||||
// === Kanban Board ===
|
// === 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.
|
// 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
|
var tasks []models.Task
|
||||||
err := r.db.Preload("CreatedBy").
|
err := r.db.Preload("CreatedBy").
|
||||||
Preload("AssignedTo").
|
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
|
// 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{
|
columns := []models.KanbanColumn{
|
||||||
{
|
{
|
||||||
@@ -217,9 +219,10 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo
|
|||||||
}, nil
|
}, 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.
|
// 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
|
var tasks []models.Task
|
||||||
err := r.db.Preload("CreatedBy").
|
err := r.db.Preload("CreatedBy").
|
||||||
Preload("AssignedTo").
|
Preload("AssignedTo").
|
||||||
@@ -238,7 +241,8 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use the categorization package as the single source of truth
|
// 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{
|
columns := []models.KanbanColumn{
|
||||||
{
|
{
|
||||||
@@ -418,13 +422,12 @@ type TaskStatistics struct {
|
|||||||
|
|
||||||
// GetTaskStatistics returns aggregated task statistics for multiple residences.
|
// GetTaskStatistics returns aggregated task statistics for multiple residences.
|
||||||
// Uses the task.scopes package for consistent filtering logic.
|
// 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 {
|
if len(residenceIDs) == 0 {
|
||||||
return &TaskStatistics{}, nil
|
return &TaskStatistics{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
var totalTasks, totalOverdue, totalPending, tasksDueNextWeek, tasksDueNextMonth int64
|
var totalTasks, totalOverdue, totalPending, tasksDueNextWeek, tasksDueNextMonth int64
|
||||||
|
|
||||||
// Count total active tasks (not cancelled, not archived)
|
// 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.
|
// GetOverdueCountByResidence returns a map of residence ID to overdue task count.
|
||||||
// Uses the task.scopes package for consistent filtering logic.
|
// 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 {
|
if len(residenceIDs) == 0 {
|
||||||
return map[uint]int{}, nil
|
return map[uint]int{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
// Query to get overdue count grouped by residence
|
// Query to get overdue count grouped by residence
|
||||||
type result struct {
|
type result struct {
|
||||||
ResidenceID uint
|
ResidenceID uint
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
|||||||
// Protected routes (auth required)
|
// Protected routes (auth required)
|
||||||
protected := api.Group("")
|
protected := api.Group("")
|
||||||
protected.Use(authMiddleware.TokenAuth())
|
protected.Use(authMiddleware.TokenAuth())
|
||||||
|
protected.Use(middleware.TimezoneMiddleware())
|
||||||
{
|
{
|
||||||
setupProtectedAuthRoutes(protected, authHandler)
|
setupProtectedAuthRoutes(protected, authHandler)
|
||||||
setupResidenceRoutes(protected, residenceHandler)
|
setupResidenceRoutes(protected, residenceHandler)
|
||||||
@@ -202,7 +203,7 @@ func corsMiddleware(cfg *config.Config) gin.HandlerFunc {
|
|||||||
return cors.New(cors.Config{
|
return cors.New(cors.Config{
|
||||||
AllowAllOrigins: true,
|
AllowAllOrigins: true,
|
||||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
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"},
|
ExposeHeaders: []string{"Content-Length"},
|
||||||
AllowCredentials: false, // Must be false when AllowAllOrigins is true
|
AllowCredentials: false, // Must be false when AllowAllOrigins is true
|
||||||
MaxAge: 12 * time.Hour,
|
MaxAge: 12 * time.Hour,
|
||||||
|
|||||||
@@ -82,8 +82,9 @@ func (s *ResidenceService) ListResidences(userID uint) ([]responses.ResidenceRes
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetMyResidences returns residences with additional details (tasks, completions, etc.)
|
// GetMyResidences returns residences with additional details (tasks, completions, etc.)
|
||||||
// This is the "my-residences" endpoint that returns richer data
|
// This is the "my-residences" endpoint that returns richer data.
|
||||||
func (s *ResidenceService) GetMyResidences(userID uint) (*responses.MyResidencesResponse, error) {
|
// 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)
|
residences, err := s.residenceRepo.FindByUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -104,8 +105,8 @@ func (s *ResidenceService) GetMyResidences(userID uint) (*responses.MyResidences
|
|||||||
residenceIDs[i] = r.ID
|
residenceIDs[i] = r.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get aggregated statistics
|
// Get aggregated statistics using user's timezone-aware time
|
||||||
stats, err := s.taskRepo.GetTaskStatistics(residenceIDs)
|
stats, err := s.taskRepo.GetTaskStatistics(residenceIDs, now)
|
||||||
if err == nil && stats != nil {
|
if err == nil && stats != nil {
|
||||||
summary.TotalTasks = stats.TotalTasks
|
summary.TotalTasks = stats.TotalTasks
|
||||||
summary.TotalPending = stats.TotalPending
|
summary.TotalPending = stats.TotalPending
|
||||||
@@ -114,8 +115,8 @@ func (s *ResidenceService) GetMyResidences(userID uint) (*responses.MyResidences
|
|||||||
summary.TasksDueNextMonth = stats.TasksDueNextMonth
|
summary.TasksDueNextMonth = stats.TasksDueNextMonth
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get per-residence overdue counts
|
// Get per-residence overdue counts using user's timezone-aware time
|
||||||
overdueCounts, err := s.taskRepo.GetOverdueCountByResidence(residenceIDs)
|
overdueCounts, err := s.taskRepo.GetOverdueCountByResidence(residenceIDs, now)
|
||||||
if err == nil && overdueCounts != nil {
|
if err == nil && overdueCounts != nil {
|
||||||
for i := range residenceResponses {
|
for i := range residenceResponses {
|
||||||
if count, ok := overdueCounts[residenceResponses[i].ID]; ok {
|
if count, ok := overdueCounts[residenceResponses[i].ID]; ok {
|
||||||
@@ -131,9 +132,10 @@ func (s *ResidenceService) GetMyResidences(userID uint) (*responses.MyResidences
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSummary returns just the task summary statistics for a user's residences
|
// 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
|
// This is a lightweight endpoint for refreshing summary counts without full residence data.
|
||||||
func (s *ResidenceService) GetSummary(userID uint) (*responses.TotalSummary, error) {
|
// 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)
|
residences, err := s.residenceRepo.FindByUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -151,8 +153,8 @@ func (s *ResidenceService) GetSummary(userID uint) (*responses.TotalSummary, err
|
|||||||
residenceIDs[i] = r.ID
|
residenceIDs[i] = r.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get aggregated statistics
|
// Get aggregated statistics using user's timezone-aware time
|
||||||
stats, err := s.taskRepo.GetTaskStatistics(residenceIDs)
|
stats, err := s.taskRepo.GetTaskStatistics(residenceIDs, now)
|
||||||
if err == nil && stats != nil {
|
if err == nil && stats != nil {
|
||||||
summary.TotalTasks = stats.TotalTasks
|
summary.TotalTasks = stats.TotalTasks
|
||||||
summary.TotalPending = stats.TotalPending
|
summary.TotalPending = stats.TotalPending
|
||||||
@@ -165,9 +167,10 @@ func (s *ResidenceService) GetSummary(userID uint) (*responses.TotalSummary, err
|
|||||||
return summary, nil
|
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 {
|
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 {
|
if err != nil || summary == nil {
|
||||||
return responses.TotalSummary{}
|
return responses.TotalSummary{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,12 +55,13 @@ func (s *TaskService) SetResidenceService(rs *ResidenceService) {
|
|||||||
s.residenceService = rs
|
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 {
|
func (s *TaskService) getSummaryForUser(userID uint) responses.TotalSummary {
|
||||||
if s.residenceService == nil {
|
if s.residenceService == nil {
|
||||||
return responses.TotalSummary{}
|
return responses.TotalSummary{}
|
||||||
}
|
}
|
||||||
summary, err := s.residenceService.GetSummary(userID)
|
summary, err := s.residenceService.GetSummary(userID, time.Now().UTC())
|
||||||
if err != nil || summary == nil {
|
if err != nil || summary == nil {
|
||||||
return responses.TotalSummary{}
|
return responses.TotalSummary{}
|
||||||
}
|
}
|
||||||
@@ -92,8 +93,9 @@ func (s *TaskService) GetTask(taskID, userID uint) (*responses.TaskResponse, err
|
|||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListTasks lists all tasks accessible to a user as a kanban board
|
// ListTasks lists all tasks accessible to a user as a kanban board.
|
||||||
func (s *TaskService) ListTasks(userID uint) (*responses.KanbanBoardResponse, error) {
|
// 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
|
// Get all residence IDs accessible to user
|
||||||
residences, err := s.residenceRepo.FindByUser(userID)
|
residences, err := s.residenceRepo.FindByUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -114,8 +116,8 @@ func (s *TaskService) ListTasks(userID uint) (*responses.KanbanBoardResponse, er
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get kanban data aggregated across all residences
|
// Get kanban data aggregated across all residences using user's timezone-aware time
|
||||||
board, err := s.taskRepo.GetKanbanDataForMultipleResidences(residenceIDs, 30)
|
board, err := s.taskRepo.GetKanbanDataForMultipleResidences(residenceIDs, 30, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -127,8 +129,9 @@ func (s *TaskService) ListTasks(userID uint) (*responses.KanbanBoardResponse, er
|
|||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTasksByResidence gets tasks for a specific residence (kanban board)
|
// GetTasksByResidence gets tasks for a specific residence (kanban board).
|
||||||
func (s *TaskService) GetTasksByResidence(residenceID, userID uint, daysThreshold int) (*responses.KanbanBoardResponse, error) {
|
// 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
|
// Check access
|
||||||
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
|
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -142,7 +145,8 @@ func (s *TaskService) GetTasksByResidence(residenceID, userID uint, daysThreshol
|
|||||||
daysThreshold = 30 // Default
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ type Context struct {
|
|||||||
DaysThreshold int
|
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 {
|
func NewContext(t *models.Task, daysThreshold int) *Context {
|
||||||
if daysThreshold <= 0 {
|
if daysThreshold <= 0 {
|
||||||
daysThreshold = 30
|
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
|
// ThresholdDate returns the date threshold for "due soon" categorization
|
||||||
func (c *Context) ThresholdDate() time.Time {
|
func (c *Context) ThresholdDate() time.Time {
|
||||||
return c.Now.AddDate(0, 0, c.DaysThreshold)
|
return c.Now.AddDate(0, 0, c.DaysThreshold)
|
||||||
@@ -207,12 +222,21 @@ func NewChain() *Chain {
|
|||||||
return &Chain{head: cancelled}
|
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 {
|
func (c *Chain) Categorize(t *models.Task, daysThreshold int) KanbanColumn {
|
||||||
ctx := NewContext(t, daysThreshold)
|
ctx := NewContext(t, daysThreshold)
|
||||||
return c.head.Handle(ctx)
|
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
|
// CategorizeWithContext uses a pre-built context for categorization
|
||||||
func (c *Chain) CategorizeWithContext(ctx *Context) KanbanColumn {
|
func (c *Chain) CategorizeWithContext(ctx *Context) KanbanColumn {
|
||||||
return c.head.Handle(ctx)
|
return c.head.Handle(ctx)
|
||||||
@@ -223,18 +247,41 @@ func (c *Chain) CategorizeWithContext(ctx *Context) KanbanColumn {
|
|||||||
// defaultChain is a singleton chain instance for convenience
|
// defaultChain is a singleton chain instance for convenience
|
||||||
var defaultChain = NewChain()
|
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 {
|
func DetermineKanbanColumn(t *models.Task, daysThreshold int) string {
|
||||||
return defaultChain.Categorize(t, daysThreshold).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 {
|
func CategorizeTask(t *models.Task, daysThreshold int) KanbanColumn {
|
||||||
return defaultChain.Categorize(t, daysThreshold)
|
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 {
|
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)
|
result := make(map[KanbanColumn][]models.Task)
|
||||||
|
|
||||||
// Initialize all columns with empty slices
|
// Initialize all columns with empty slices
|
||||||
@@ -248,7 +295,7 @@ func CategorizeTasksIntoColumns(tasks []models.Task, daysThreshold int) map[Kanb
|
|||||||
// Categorize each task
|
// Categorize each task
|
||||||
chain := NewChain()
|
chain := NewChain()
|
||||||
for _, t := range tasks {
|
for _, t := range tasks {
|
||||||
column := chain.Categorize(&t, daysThreshold)
|
column := chain.CategorizeWithTime(&t, daysThreshold, now)
|
||||||
result[column] = append(result[column], t)
|
result[column] = append(result[column], t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user