fix(calendar): correct event timezone and day bounds

This commit is contained in:
Peter Steinberger 2026-04-20 14:16:50 +01:00
parent b371214104
commit df3e97faed
No known key found for this signature in database
6 changed files with 42 additions and 61 deletions

View File

@ -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
}

View File

@ -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", ""},

View File

@ -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{

View File

@ -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.

View File

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

View File

@ -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)