diff --git a/internal/cmd/calendar_events_test.go b/internal/cmd/calendar_events_test.go index 2412f91a..78084870 100644 --- a/internal/cmd/calendar_events_test.go +++ b/internal/cmd/calendar_events_test.go @@ -114,6 +114,74 @@ func TestCalendarEventsCmd_DefaultsToPrimary(t *testing.T) { } } +func TestCalendarEventsCmd_TodayPrimaryAliasNotFound(t *testing.T) { + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + + const calendarID = "oskar@firstmovr.com" + svc, closeServer := newTestCalendarService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.ReplaceAll(r.URL.Path, "%40", "@") + switch { + case strings.Contains(path, "/calendarList/primary") && r.Method == http.MethodGet: + http.NotFound(w, r) + return + case strings.Contains(path, "/calendarList") && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": calendarID, + "summary": "Workspace Calendar", + "timeZone": "America/New_York", + "primary": true, + }, + }, + }) + return + case strings.Contains(path, "/calendars/"+calendarID+"/events") && r.Method == http.MethodGet: + if strings.TrimSpace(r.URL.Query().Get("timeMin")) == "" { + t.Errorf("expected timeMin query param") + } + if strings.TrimSpace(r.URL.Query().Get("timeMax")) == "" { + t.Errorf("expected timeMax query param") + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"id": "e1", "summary": "Event"}, + }, + }) + return + default: + http.NotFound(w, r) + return + } + })) + defer closeServer() + newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil } + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + flags := &RootFlags{Account: "a@b.com"} + + cmd := &CalendarEventsCmd{ + CalendarID: calendarID, + Today: true, + } + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "\"events\"") { + t.Fatalf("unexpected output: %q", out) + } +} + func TestCalendarEventsCmd_CalendarsFlag(t *testing.T) { origNew := newCalendarService t.Cleanup(func() { newCalendarService = origNew }) diff --git a/internal/cmd/time_helpers.go b/internal/cmd/time_helpers.go index cf6aa75d..4b16fe93 100644 --- a/internal/cmd/time_helpers.go +++ b/internal/cmd/time_helpers.go @@ -2,11 +2,14 @@ package cmd import ( "context" + "errors" "fmt" + "net/http" "strings" "time" "google.golang.org/api/calendar/v3" + "google.golang.org/api/googleapi" "github.com/steipete/gogcli/internal/timeparse" ) @@ -51,26 +54,78 @@ func getCalendarLocation(ctx context.Context, svc *calendar.Service, calendarID return cal.TimeZone, loc, nil } -// getUserTimezone fetches the timezone from the user's primary calendar. +// getUserTimezone fetches the user's timezone. +// It prefers calendarList/primary, but falls back to calendarList entries because +// some Workspace accounts return 404 for "primary". func getUserTimezone(ctx context.Context, svc *calendar.Service) (*time.Location, error) { - cal, err := svc.CalendarList.Get("primary").Context(ctx).Do() - if err != nil { + cal, err := svc.CalendarList.Get(primaryCalendarID).Context(ctx).Do() + if err == nil { + return loadTimezoneOrUTC(cal.TimeZone) + } + + if !isGoogleNotFound(err) { return nil, fmt.Errorf("failed to get primary calendar: %w", err) } - if cal.TimeZone == "" { - // Fall back to UTC if no timezone set - return time.UTC, nil + calendars, listErr := listCalendarList(ctx, svc) + if listErr != nil { + return nil, fmt.Errorf("failed to get primary calendar and fallback calendar list lookup: %w", errors.Join(err, listErr)) } - loc, err := time.LoadLocation(cal.TimeZone) - if err != nil { - return nil, fmt.Errorf("invalid calendar timezone %q: %w", cal.TimeZone, err) + return timezoneFromCalendarList(calendars) +} + +func timezoneFromCalendarList(calendars []*calendar.CalendarListEntry) (*time.Location, error) { + ordered := make([]*calendar.CalendarListEntry, 0, len(calendars)) + for _, cal := range calendars { + if cal != nil && cal.Primary { + ordered = append(ordered, cal) + } + } + for _, cal := range calendars { + if cal != nil && !cal.Primary { + ordered = append(ordered, cal) + } + } + + var firstInvalid error + for _, cal := range ordered { + tz := strings.TrimSpace(cal.TimeZone) + if tz == "" { + continue + } + loc, err := time.LoadLocation(tz) + if err != nil { + if firstInvalid == nil { + firstInvalid = fmt.Errorf("invalid calendar timezone %q on calendar %q: %w", tz, cal.Id, err) + } + continue + } + return loc, nil } + if firstInvalid != nil { + return nil, firstInvalid + } + + return time.UTC, nil +} +func loadTimezoneOrUTC(timezone string) (*time.Location, error) { + if strings.TrimSpace(timezone) == "" { + return time.UTC, nil + } + loc, err := time.LoadLocation(timezone) + if err != nil { + return nil, fmt.Errorf("invalid calendar timezone %q: %w", timezone, err) + } return loc, nil } +func isGoogleNotFound(err error) bool { + var gerr *googleapi.Error + return errors.As(err, &gerr) && gerr.Code == http.StatusNotFound +} + // ResolveTimeRange resolves the time range flags into absolute times. // If no flags are provided, defaults to "next 7 days" from now. func ResolveTimeRange(ctx context.Context, svc *calendar.Service, flags TimeRangeFlags) (*TimeRange, error) { diff --git a/internal/cmd/time_range_more_test.go b/internal/cmd/time_range_more_test.go index 846a0443..15b03418 100644 --- a/internal/cmd/time_range_more_test.go +++ b/internal/cmd/time_range_more_test.go @@ -162,6 +162,44 @@ func TestGetUserTimezoneInvalid(t *testing.T) { } } +func TestGetUserTimezonePrimaryNotFoundFallsBackToCalendarList(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/calendarList/primary") && r.Method == http.MethodGet: + http.NotFound(w, r) + return + case strings.Contains(r.URL.Path, "/calendarList") && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"id": "team@example.com", "summary": "Team", "timeZone": "America/Los_Angeles"}, + {"id": "user@example.com", "summary": "User", "timeZone": "America/New_York", "primary": true}, + }, + }) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + + svc, err := calendar.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + + loc, err := getUserTimezone(context.Background(), svc) + if err != nil { + t.Fatalf("getUserTimezone: %v", err) + } + if loc.String() != "America/New_York" { + t.Fatalf("expected primary calendar timezone fallback, got %q", loc.String()) + } +} + func TestResolveTimeRangeWithDefaultsToTomorrowEndOfDay(t *testing.T) { svc := newCalendarServiceWithTimezone(t, "UTC") flags := TimeRangeFlags{