summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-26 16:44:33 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-26 16:44:33 -1000
commit8de1b5cb8915ed9a6e32566431d05fafafeb338d (patch)
tree5a3f6e495537a9a9cab33d5adc6d89cd3cbdc60f
parentb4d061cb93d992febb5b70c9d7645afdd3a41890 (diff)
Fix calendar timezone handling with configurable display timezone
- Add TIMEZONE config option (defaults to Pacific/Honolulu) - Store display timezone in GoogleCalendarClient - Convert all event times to configured display timezone - Parse events in their native timezone then convert for display This fixes the issue where events were showing 10 hours off due to server running in UTC while user is in Hawaii. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--cmd/dashboard/main.go12
-rw-r--r--internal/api/google_calendar.go74
-rw-r--r--internal/config/config.go33
3 files changed, 77 insertions, 42 deletions
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index b3eab14..f639944 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -93,16 +93,16 @@ func main() {
// Use timeout context to prevent startup hangs if credentials file is unreachable
initCtx, cancel := context.WithTimeout(context.Background(), config.GoogleCalendarInitTimeout)
var err error
- googleCalendarClient, err = api.NewGoogleCalendarClient(initCtx, cfg.GoogleCredentialsFile, cfg.GoogleCalendarID)
+ googleCalendarClient, err = api.NewGoogleCalendarClient(initCtx, cfg.GoogleCredentialsFile, cfg.GoogleCalendarID, cfg.Timezone)
cancel()
if err != nil {
log.Printf("Warning: failed to initialize Google Calendar client: %v", err)
} else {
- log.Printf("Google Calendar client initialized for calendars: %s", cfg.GoogleCalendarID)
+ log.Printf("Google Calendar client initialized for calendars: %s (timezone: %s)", cfg.GoogleCalendarID, cfg.Timezone)
}
}
- // Initialize handlers
+ // Initia lize handlers
h := handlers.New(db, todoistClient, trelloClient, planToEatClient, googleCalendarClient, cfg)
// Set up router
@@ -112,9 +112,9 @@ func main() {
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(config.RequestTimeout))
- r.Use(appmiddleware.SecurityHeaders(cfg.Debug)) // Security headers
- r.Use(sessionManager.LoadAndSave) // Session middleware must be applied globally
- r.Use(authHandlers.Middleware().CSRFProtect) // CSRF protection
+ r.Use(appmiddleware.SecurityHeaders(cfg.Debug)) // Security headers
+ r.Use(sessionManager.LoadAndSave) // Session middleware must be applied globally
+ r.Use(authHandlers.Middleware().CSRFProtect) // CSRF protection
// Rate limiter for auth endpoints
authRateLimiter := appmiddleware.NewRateLimiter(config.AuthRateLimitRequests, config.AuthRateLimitWindow)
diff --git a/internal/api/google_calendar.go b/internal/api/google_calendar.go
index 1b1971a..d2d4355 100644
--- a/internal/api/google_calendar.go
+++ b/internal/api/google_calendar.go
@@ -17,38 +17,51 @@ import (
type GoogleCalendarClient struct {
srv *calendar.Service
calendarIDs []string
+ displayTZ *time.Location
}
-// parseEventTime extracts start/end times from a Google Calendar event, converting to local timezone
-func parseEventTime(item *calendar.Event) (start, end time.Time) {
+// parseEventTime extracts start/end times from a Google Calendar event
+func (c *GoogleCalendarClient) parseEventTime(item *calendar.Event) (start, end time.Time) {
+ displayTZ := c.displayTZ
+ if displayTZ == nil {
+ displayTZ = time.UTC
+ }
+
if item.Start.DateTime == "" {
- // All-day event - parse in local timezone
- start, _ = time.ParseInLocation("2006-01-02", item.Start.Date, time.Local)
- end, _ = time.ParseInLocation("2006-01-02", item.End.Date, time.Local)
+ // All-day event - parse in display timezone
+ start, _ = time.ParseInLocation("2006-01-02", item.Start.Date, displayTZ)
+ end, _ = time.ParseInLocation("2006-01-02", item.End.Date, displayTZ)
} else {
- // Timed event - use the event's timezone if specified
- var loc *time.Location
- if item.Start.TimeZone != "" {
- loc, _ = time.LoadLocation(item.Start.TimeZone)
- }
- if loc == nil {
- loc = time.Local
+ // Try RFC3339 first (includes timezone offset like "2006-01-02T15:04:05-10:00" or "Z")
+ var err error
+ start, err = time.Parse(time.RFC3339, item.Start.DateTime)
+ if err != nil {
+ // No timezone in string - use event's timezone or display timezone
+ var loc *time.Location
+ if item.Start.TimeZone != "" {
+ loc, _ = time.LoadLocation(item.Start.TimeZone)
+ }
+ if loc == nil {
+ loc = displayTZ
+ }
+ start, _ = time.ParseInLocation("2006-01-02T15:04:05", item.Start.DateTime, loc)
}
- // Try parsing with timezone offset first (RFC3339)
- start, _ = time.Parse(time.RFC3339, item.Start.DateTime)
- end, _ = time.Parse(time.RFC3339, item.End.DateTime)
-
- // If the parsed time is zero (parse failed) or missing timezone info,
- // parse in the event's timezone
- if start.IsZero() || item.Start.DateTime[len(item.Start.DateTime)-1] != 'Z' && !strings.Contains(item.Start.DateTime, "+") && !strings.Contains(item.Start.DateTime, "-") {
- start, _ = time.ParseInLocation("2006-01-02T15:04:05", item.Start.DateTime, loc)
+ end, err = time.Parse(time.RFC3339, item.End.DateTime)
+ if err != nil {
+ var loc *time.Location
+ if item.End.TimeZone != "" {
+ loc, _ = time.LoadLocation(item.End.TimeZone)
+ }
+ if loc == nil {
+ loc = displayTZ
+ }
end, _ = time.ParseInLocation("2006-01-02T15:04:05", item.End.DateTime, loc)
}
- // Convert to local timezone for display
- start = start.In(time.Local)
- end = end.In(time.Local)
+ // Convert to display timezone
+ start = start.In(displayTZ)
+ end = end.In(displayTZ)
}
return
}
@@ -72,7 +85,8 @@ func deduplicateEvents(events []models.CalendarEvent) []models.CalendarEvent {
// NewGoogleCalendarClient creates a client that fetches from multiple calendars.
// calendarIDs can be comma-separated (e.g., "cal1@group.calendar.google.com,cal2@group.calendar.google.com")
-func NewGoogleCalendarClient(ctx context.Context, credentialsFile, calendarIDs string) (*GoogleCalendarClient, error) {
+// timezone is the IANA timezone name for display (e.g., "Pacific/Honolulu")
+func NewGoogleCalendarClient(ctx context.Context, credentialsFile, calendarIDs, timezone string) (*GoogleCalendarClient, error) {
srv, err := calendar.NewService(ctx, option.WithCredentialsFile(credentialsFile))
if err != nil {
return nil, fmt.Errorf("unable to retrieve Calendar client: %v", err)
@@ -87,9 +101,17 @@ func NewGoogleCalendarClient(ctx context.Context, credentialsFile, calendarIDs s
}
}
+ // Load display timezone
+ displayTZ, err := time.LoadLocation(timezone)
+ if err != nil {
+ log.Printf("Warning: invalid timezone %q, using UTC: %v", timezone, err)
+ displayTZ = time.UTC
+ }
+
return &GoogleCalendarClient{
srv: srv,
calendarIDs: trimmedIDs,
+ displayTZ: displayTZ,
}, nil
}
@@ -106,7 +128,7 @@ func (c *GoogleCalendarClient) GetUpcomingEvents(ctx context.Context, maxResults
}
for _, item := range events.Items {
- start, end := parseEventTime(item)
+ start, end := c.parseEventTime(item)
allEvents = append(allEvents, models.CalendarEvent{
ID: item.Id,
Summary: item.Summary,
@@ -139,7 +161,7 @@ func (c *GoogleCalendarClient) GetEventsByDateRange(ctx context.Context, start,
}
for _, item := range events.Items {
- evtStart, evtEnd := parseEventTime(item)
+ evtStart, evtEnd := c.parseEventTime(item)
allEvents = append(allEvents, models.CalendarEvent{
ID: item.Id,
Summary: item.Summary,
diff --git a/internal/config/config.go b/internal/config/config.go
index 62e733a..cf3af49 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -9,10 +9,11 @@ import (
// Config holds all application configuration
type Config struct {
// API Keys
- TodoistAPIKey string
- PlanToEatAPIKey string
- TrelloAPIKey string
- TrelloToken string
+ TodoistAPIKey string
+ PlanToEatAPIKey string
+ PlanToEatSession string // Session cookie for web scraping
+ TrelloAPIKey string
+ TrelloToken string
// Google Calendar
GoogleCredentialsFile string
@@ -28,16 +29,20 @@ type Config struct {
Port string
CacheTTLMinutes int
Debug bool
+
+ // Display
+ Timezone string // IANA timezone name (e.g., "Pacific/Honolulu")
}
// Load reads configuration from environment variables
func Load() (*Config, error) {
cfg := &Config{
// API Keys
- TodoistAPIKey: os.Getenv("TODOIST_API_KEY"),
- PlanToEatAPIKey: os.Getenv("PLANTOEAT_API_KEY"),
- TrelloAPIKey: os.Getenv("TRELLO_API_KEY"),
- TrelloToken: os.Getenv("TRELLO_TOKEN"),
+ TodoistAPIKey: os.Getenv("TODOIST_API_KEY"),
+ PlanToEatAPIKey: os.Getenv("PLANTOEAT_API_KEY"),
+ PlanToEatSession: os.Getenv("PLANTOEAT_SESSION"),
+ TrelloAPIKey: os.Getenv("TRELLO_API_KEY"),
+ TrelloToken: os.Getenv("TRELLO_TOKEN"),
// Google Calendar
GoogleCredentialsFile: os.Getenv("GOOGLE_CREDENTIALS_FILE"),
@@ -53,6 +58,9 @@ func Load() (*Config, error) {
Port: getEnvWithDefault("PORT", "8080"),
CacheTTLMinutes: getEnvAsInt("CACHE_TTL_MINUTES", 5),
Debug: getEnvAsBool("DEBUG", false),
+
+ // Display
+ Timezone: getEnvWithDefault("TIMEZONE", "Pacific/Honolulu"),
}
// Validate required fields
@@ -81,9 +89,14 @@ func (c *Config) Validate() error {
return nil
}
-// HasPlanToEat checks if PlanToEat is configured
+// HasPlanToEat checks if PlanToEat is configured (API key or session)
func (c *Config) HasPlanToEat() bool {
- return c.PlanToEatAPIKey != ""
+ return c.PlanToEatAPIKey != "" || c.PlanToEatSession != ""
+}
+
+// HasPlanToEatSession checks if PlanToEat session cookie is configured
+func (c *Config) HasPlanToEatSession() bool {
+ return c.PlanToEatSession != ""
}
// HasTrello checks if Trello is configured