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:
Trey t
2025-12-13 00:04:09 -06:00
parent a568658b58
commit 684856e0e9
8 changed files with 206 additions and 45 deletions

View 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)
}