From df3e97faed3fd2e120281c658f836edb10edd2c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Apr 2026 14:16:50 +0100 Subject: [PATCH] fix(calendar): correct event timezone and day bounds --- internal/cmd/calendar_build.go | 37 ++++------------------------ internal/cmd/calendar_build_test.go | 14 +++++------ internal/cmd/calendar_focus_time.go | 4 +-- internal/cmd/time_helpers.go | 7 ++++-- internal/cmd/time_helpers_test.go | 22 ++++++++--------- internal/cmd/time_range_more_test.go | 19 ++++++++------ 6 files changed, 42 insertions(+), 61 deletions(-) diff --git a/internal/cmd/calendar_build.go b/internal/cmd/calendar_build.go index 0d69f08..4858ee2 100644 --- a/internal/cmd/calendar_build.go +++ b/internal/cmd/calendar_build.go @@ -40,31 +40,8 @@ func etcGMTForOffsetSeconds(offset int) (string, bool) { return fmt.Sprintf("Etc/GMT+%d", -hours), true } -func usIANAForOffsetAt(t time.Time, offset int) string { - switch offset { - case -4 * 3600, -5 * 3600, -6 * 3600, -7 * 3600, -8 * 3600: - for _, candidate := range []string{ - "America/New_York", - "America/Chicago", - "America/Denver", - "America/Phoenix", - "America/Los_Angeles", - } { - loc, err := time.LoadLocation(candidate) - if err != nil { - continue - } - _, candidateOffset := t.In(loc).Zone() - if candidateOffset == offset { - return candidate - } - } - } - return "" -} - // extractTimezone attempts to determine a timezone from an RFC3339 datetime string. -// Returns an IANA timezone name if determinable, empty string otherwise. +// Returns a timezone identifier safe to send to Google Calendar. func extractTimezone(value string) string { t, err := time.Parse(time.RFC3339, value) if err != nil { @@ -76,14 +53,10 @@ func extractTimezone(value string) string { return tzUTC } - // RFC3339 values have a fixed offset, but Google Calendar requires an IANA timezone - // name for recurring events. We guess by checking which common zones match the - // offset at this instant. - if tz := usIANAForOffsetAt(t, offset); tz != "" { - return tz - } - - // Fallback for fixed whole-hour offsets when no regional timezone match is found. + // Offset-only RFC3339 values do not carry enough information to distinguish + // between regional IANA zones that may share the same offset at a given instant + // (for example America/Phoenix vs America/Los_Angeles during DST). Use a fixed + // offset timezone instead of guessing an ambiguous named region. if tz, ok := etcGMTForOffsetSeconds(offset); ok { return tz } diff --git a/internal/cmd/calendar_build_test.go b/internal/cmd/calendar_build_test.go index 74a7dfe..cebafb7 100644 --- a/internal/cmd/calendar_build_test.go +++ b/internal/cmd/calendar_build_test.go @@ -7,13 +7,13 @@ func TestExtractTimezone(t *testing.T) { input string expected string }{ - {"2026-01-08T11:00:00-05:00", "America/New_York"}, - {"2026-07-08T11:00:00-04:00", "America/New_York"}, - {"2026-01-08T11:00:00-06:00", "America/Chicago"}, - {"2026-07-08T11:00:00-05:00", "America/Chicago"}, - {"2026-01-08T11:00:00-07:00", "America/Denver"}, - {"2026-07-08T11:00:00-07:00", "America/Phoenix"}, - {"2026-01-08T11:00:00-08:00", "America/Los_Angeles"}, + {"2026-01-08T11:00:00-05:00", "Etc/GMT+5"}, + {"2026-07-08T11:00:00-04:00", "Etc/GMT+4"}, + {"2026-01-08T11:00:00-06:00", "Etc/GMT+6"}, + {"2026-07-08T11:00:00-05:00", "Etc/GMT+5"}, + {"2026-01-08T11:00:00-07:00", "Etc/GMT+7"}, + {"2026-07-08T11:00:00-07:00", "Etc/GMT+7"}, + {"2026-01-08T11:00:00-08:00", "Etc/GMT+8"}, {"2026-01-08T16:00:00Z", "UTC"}, {"2026-01-08T11:00:00+00:00", "UTC"}, {"invalid", ""}, diff --git a/internal/cmd/calendar_focus_time.go b/internal/cmd/calendar_focus_time.go index af46465..623c37b 100644 --- a/internal/cmd/calendar_focus_time.go +++ b/internal/cmd/calendar_focus_time.go @@ -36,8 +36,8 @@ func (c *CalendarFocusTimeCmd) Run(ctx context.Context, flags *RootFlags) error event := &calendar.Event{ Summary: strings.TrimSpace(c.Summary), - Start: &calendar.EventDateTime{DateTime: strings.TrimSpace(c.From)}, - End: &calendar.EventDateTime{DateTime: strings.TrimSpace(c.To)}, + Start: buildEventDateTime(c.From, false), + End: buildEventDateTime(c.To, false), EventType: eventTypeFocusTime, Transparency: "opaque", FocusTimeProperties: &calendar.EventFocusTimeProperties{ diff --git a/internal/cmd/time_helpers.go b/internal/cmd/time_helpers.go index 29dc96a..7b5027f 100644 --- a/internal/cmd/time_helpers.go +++ b/internal/cmd/time_helpers.go @@ -267,9 +267,12 @@ func startOfDay(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) } -// endOfDay returns the end of the day (23:59:59.999) in the given time's location. +// endOfDay returns the start of the next day so that when formatted as +// RFC3339 (second precision) and used as an exclusive timeMax, the entire +// target day is included. Previously, 23:59:59.999999999 was truncated to +// 23:59:59 by time.RFC3339, which excluded events starting at that second. func endOfDay(t time.Time) time.Time { - return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 999999999, t.Location()) + return startOfDay(t.AddDate(0, 0, 1)) } // startOfWeek returns the start of the week for the given time. diff --git a/internal/cmd/time_helpers_test.go b/internal/cmd/time_helpers_test.go index bc86515..057f80e 100644 --- a/internal/cmd/time_helpers_test.go +++ b/internal/cmd/time_helpers_test.go @@ -137,13 +137,13 @@ func TestWeekBounds(t *testing.T) { now := time.Date(2025, 1, 8, 12, 0, 0, 0, time.UTC) // Wednesday start := startOfWeek(now, time.Monday) end := endOfWeek(now, time.Monday) - if start.Weekday() != time.Monday || end.Weekday() != time.Sunday { + if start.Weekday() != time.Monday || !end.Equal(start.AddDate(0, 0, 7)) { t.Fatalf("unexpected week bounds: %v to %v", start.Weekday(), end.Weekday()) } startSun := startOfWeek(now, time.Sunday) endSun := endOfWeek(now, time.Sunday) - if startSun.Weekday() != time.Sunday || endSun.Weekday() != time.Saturday { + if startSun.Weekday() != time.Sunday || !endSun.Equal(startSun.AddDate(0, 0, 7)) { t.Fatalf("unexpected week bounds (sun): %v to %v", startSun.Weekday(), endSun.Weekday()) } } @@ -156,7 +156,7 @@ func TestDayBounds(t *testing.T) { t.Fatalf("unexpected startOfDay: %v", start) } - if end.Hour() != 23 || end.Minute() != 59 || end.Second() != 59 { + if !end.Equal(start.AddDate(0, 0, 1)) { t.Fatalf("unexpected endOfDay: %v", end) } } @@ -165,24 +165,24 @@ func TestParseTimeExprEndOfDay(t *testing.T) { now := time.Date(2025, 1, 10, 12, 0, 0, 0, time.UTC) loc := time.FixedZone("IST", 5*3600+30*60) - // Date-only should resolve to end of day + // Date-only should resolve to the exclusive upper bound: next midnight. parsed, err := parseTimeExprEndOfDay("2025-01-05", now, loc) if err != nil { t.Fatalf("parseTimeExprEndOfDay date: %v", err) } - if parsed.Hour() != 23 || parsed.Minute() != 59 || parsed.Second() != 59 { + if !parsed.Equal(time.Date(2025, 1, 6, 0, 0, 0, 0, loc)) { t.Fatalf("expected end of day, got %v", parsed) } if parsed.Location() != loc { t.Fatalf("expected loc %v, got %v", loc, parsed.Location()) } - // Relative "today" should resolve to end of day + // Relative "today" should resolve to the exclusive upper bound: next midnight. parsed, err = parseTimeExprEndOfDay("today", now, time.UTC) if err != nil { t.Fatalf("parseTimeExprEndOfDay today: %v", err) } - if parsed.Hour() != 23 || parsed.Minute() != 59 || parsed.Second() != 59 { + if !parsed.Equal(time.Date(2025, 1, 11, 0, 0, 0, 0, time.UTC)) { t.Fatalf("expected end of day for today, got %v", parsed) } @@ -222,21 +222,21 @@ func TestParseTimeExprEndOfDay(t *testing.T) { t.Fatalf("local datetime should be preserved, got %v", parsed) } - // Weekday expression should resolve to end of day + // Weekday expression should resolve to the exclusive upper bound: next midnight. parsed, err = parseTimeExprEndOfDay("monday", now, time.UTC) if err != nil { t.Fatalf("parseTimeExprEndOfDay monday: %v", err) } - if parsed.Hour() != 23 || parsed.Minute() != 59 || parsed.Second() != 59 { + if !parsed.Equal(time.Date(2025, 1, 14, 0, 0, 0, 0, time.UTC)) { t.Fatalf("expected end of day for monday, got %v", parsed) } - // "tomorrow" should resolve to end of day + // "tomorrow" should resolve to the exclusive upper bound: next midnight. parsed, err = parseTimeExprEndOfDay("tomorrow", now, time.UTC) if err != nil { t.Fatalf("parseTimeExprEndOfDay tomorrow: %v", err) } - if parsed.Hour() != 23 || parsed.Minute() != 59 || parsed.Second() != 59 { + if !parsed.Equal(time.Date(2025, 1, 12, 0, 0, 0, 0, time.UTC)) { t.Fatalf("expected end of day for tomorrow, got %v", parsed) } diff --git a/internal/cmd/time_range_more_test.go b/internal/cmd/time_range_more_test.go index cd7e29a..7157ad0 100644 --- a/internal/cmd/time_range_more_test.go +++ b/internal/cmd/time_range_more_test.go @@ -48,8 +48,11 @@ func TestResolveTimeRangeWithDefaultsToday(t *testing.T) { t.Fatalf("expected start of day, got %v", tr.From) } - if tr.To.Hour() != 23 || tr.To.Minute() != 59 || tr.To.Second() != 59 { - t.Fatalf("expected end of day, got %v", tr.To) + // endOfDay now returns start of next day (midnight) so that RFC3339 + // second-precision formatting doesn't truncate 23:59:59.999999999 to + // 23:59:59, which would exclude events at that second via exclusive timeMax. + if tr.To.Hour() != 0 || tr.To.Minute() != 0 || tr.To.Second() != 0 { + t.Fatalf("expected start of next day (end-of-day), got %v", tr.To) } if !tr.From.Before(tr.To) { @@ -87,7 +90,7 @@ func TestResolveTimeRangeWithDefaultsToDateOnlyEndOfDay(t *testing.T) { } expectedFrom := time.Date(2025, 1, 5, 10, 0, 0, 0, time.UTC) - expectedTo := time.Date(2025, 1, 5, 23, 59, 59, 999999999, time.UTC) + expectedTo := time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC) if !tr.From.Equal(expectedFrom) { t.Fatalf("unexpected from: %v", tr.From) } @@ -230,7 +233,8 @@ func TestResolveTimeRangeWithDefaultsToTomorrowEndOfDay(t *testing.T) { // "tomorrow" is relative to now, so we calculate expected tomorrow expectedTomorrow := now.AddDate(0, 0, 1) - expectedTo := time.Date(expectedTomorrow.Year(), expectedTomorrow.Month(), expectedTomorrow.Day(), 23, 59, 59, 999999999, time.UTC) + // endOfDay returns start of next day, so for tomorrow that's day after tomorrow + expectedTo := time.Date(expectedTomorrow.Year(), expectedTomorrow.Month(), expectedTomorrow.Day()+1, 0, 0, 0, 0, time.UTC) if !tr.To.Equal(expectedTo) { t.Fatalf("expected --to tomorrow to expand to end-of-day %v, got %v", expectedTo, tr.To) @@ -261,8 +265,8 @@ func TestResolveTimeRangeWithDefaultsToNowNoExpansion(t *testing.T) { t.Fatalf("expected --to now to be current time (between %v and %v), got %v", before, after, tr.To) } - // Verify it's NOT end-of-day (23:59:59.999999999) - if tr.To.Hour() == 23 && tr.To.Minute() == 59 && tr.To.Second() == 59 && tr.To.Nanosecond() == 999999999 { + // Verify it's NOT midnight of next day (the new endOfDay) + if tr.To.Hour() == 0 && tr.To.Minute() == 0 && tr.To.Second() == 0 && tr.To.Nanosecond() == 0 { t.Fatalf("expected --to now NOT to expand to end-of-day, but got %v", tr.To) } } @@ -295,7 +299,8 @@ func TestResolveTimeRangeWithDefaultsToMondayEndOfDay(t *testing.T) { daysUntil += 7 } expectedMonday := now.AddDate(0, 0, daysUntil) - expectedTo := time.Date(expectedMonday.Year(), expectedMonday.Month(), expectedMonday.Day(), 23, 59, 59, 999999999, time.UTC) + // endOfDay returns start of next day + expectedTo := time.Date(expectedMonday.Year(), expectedMonday.Month(), expectedMonday.Day()+1, 0, 0, 0, 0, time.UTC) if !tr.To.Equal(expectedTo) { t.Fatalf("expected --to monday to expand to end-of-day %v, got %v", expectedTo, tr.To)