fix(calendar): correct event timezone and day bounds
This commit is contained in:
parent
b371214104
commit
df3e97faed
@ -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
|
||||
}
|
||||
|
||||
@ -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", ""},
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user