From c8ebaba6e04169359d84e00ffa2d797f40bc077e Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Thu, 6 Aug 2026 01:50:56 +0000 Subject: Fix Google Calendar TimeMin using server time instead of display timezone GetUpcomingEvents built its TimeMin cutoff from raw time.Now() (server/UTC time), not the start of today in the configured display timezone. Google's API filters TimeMin against each event's END time (exclusive), so for a UTC-10 display timezone (Pacific/Honolulu) that cutoff landed mid-afternoon the PREVIOUS Hawaii-local day, not midnight of the actual display day. Confirmed against live data before fixing, not assumed: the cached calendar_events table's earliest row was "2026-08-05 15:00:00-10:00" -- exactly the mid-afternoon-yesterday skew this bug produces -- with a complete gap for all of 2026-08-06 (today). This is what caused two symptoms reported against the widget: already-ended-today events missing entirely (never fetched into the cache in the first place, not a client-side rendering bug -- the 2026-08-05 Android "past events" feature was built correctly but had nothing to render) and the tomorrow section appearing empty (same skewed data window scrambling the day-bucketing downstream). Fix: compute TimeMin from time.Now().In(displayTZ)'s start-of-day instead. Added TestGetUpcomingEvents_TimeMinIsStartOfTodayInDisplayTimezone, which asserts the actual outgoing timeMin query parameter is midnight-in-tz and lands on today's date -- verified it catches the regression by reverting to raw time.Now() against a real backup and confirming the exact failure mode (captured timeMin = mid-afternoon the day before), then restored. go test ./... -race is green. Deployed. Note: the calendar cache only refreshes when the web dashboard is visited (aggregateData) -- the widget's own refresh only re-reads the DB cache, never triggers a live Calendar resync -- so the currently-cached stale data needs one dashboard visit to actually reflect this fix, not just the deploy. --- internal/api/google_calendar.go | 19 +++++++++++++++- internal/api/google_calendar_test.go | 44 ++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) (limited to 'internal') diff --git a/internal/api/google_calendar.go b/internal/api/google_calendar.go index 44457bf..a69f734 100644 --- a/internal/api/google_calendar.go +++ b/internal/api/google_calendar.go @@ -117,7 +117,24 @@ func NewGoogleCalendarClient(ctx context.Context, credentialsFile, calendarIDs, } func (c *GoogleCalendarClient) GetUpcomingEvents(ctx context.Context, maxResults int) ([]models.CalendarEvent, error) { - t := time.Now().Format(time.RFC3339) + // TimeMin must be the start of today in the DISPLAY timezone, not raw + // time.Now() (server/UTC time). Google's API filters TimeMin against + // each event's END time (exclusive), so this is also what makes an + // already-ended-today event "past" vs excluded entirely -- using + // time.Now() here meant, for a UTC-10 display timezone, the cutoff + // landed mid-afternoon the PREVIOUS Hawaii-local day (server midnight + // UTC is 2pm HST the day before), not midnight of the actual display + // day. That's what caused the 2026-08-06 report: past-today events + // missing entirely (silently never fetched into the cache in the first + // place, not a client-side bug) and the whole today/tomorrow bucketing + // downstream skewed by the same ~10h gap. + tz := c.displayTZ + if tz == nil { + tz = time.UTC + } + nowInTZ := time.Now().In(tz) + todayStart := time.Date(nowInTZ.Year(), nowInTZ.Month(), nowInTZ.Day(), 0, 0, 0, 0, tz) + t := todayStart.Format(time.RFC3339) fetchOne := func(calendarID string) []models.CalendarEvent { events, err := c.srv.Events.List(calendarID).ShowDeleted(false). diff --git a/internal/api/google_calendar_test.go b/internal/api/google_calendar_test.go index 3cf0dbd..92fdea3 100644 --- a/internal/api/google_calendar_test.go +++ b/internal/api/google_calendar_test.go @@ -272,3 +272,47 @@ func TestGetUpcomingEvents_APIError_ReturnsEmptyNotError(t *testing.T) { t.Errorf("expected 0 events on API error, got %d", len(events)) } } + +// Regression test for the 2026-08-06 incident: GetUpcomingEvents used +// time.Now() (server/UTC time) as TimeMin instead of the start of today in +// the display timezone. Google's API filters TimeMin against each event's +// END time, so for Pacific/Honolulu (UTC-10) that cutoff landed mid-afternoon +// the PREVIOUS Hawaii-local day, not midnight of the actual display day -- +// silently excluding every already-ended-today event from ever being +// fetched into the cache, and skewing the whole today/tomorrow split +// downstream by the same ~10h gap. This would have caught it: raw +// time.Now() fails both assertions below for any negative-UTC-offset zone. +func TestGetUpcomingEvents_TimeMinIsStartOfTodayInDisplayTimezone(t *testing.T) { + var capturedTimeMin string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedTimeMin = r.URL.Query().Get("timeMin") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"kind":"calendar#events","items":[]}`)) + })) + defer server.Close() + + tz, err := time.LoadLocation("Pacific/Honolulu") + if err != nil { + t.Fatalf("failed to load Pacific/Honolulu: %v", err) + } + client := newTestGoogleCalendarClient(t, server, []string{"primary"}, tz) + if _, err := client.GetUpcomingEvents(context.Background(), 10); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, err := time.Parse(time.RFC3339, capturedTimeMin) + if err != nil { + t.Fatalf("captured timeMin %q isn't valid RFC3339: %v", capturedTimeMin, err) + } + gotInTZ := got.In(tz) + + if gotInTZ.Hour() != 0 || gotInTZ.Minute() != 0 || gotInTZ.Second() != 0 { + t.Errorf("timeMin = %v (in %s), want midnight", gotInTZ, tz) + } + + nowInTZ := time.Now().In(tz) + if gotInTZ.Year() != nowInTZ.Year() || gotInTZ.YearDay() != nowInTZ.YearDay() { + t.Errorf("timeMin day = %s, want today (%s) in %s", + gotInTZ.Format("2006-01-02"), nowInTZ.Format("2006-01-02"), tz) + } +} -- cgit v1.2.3