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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user