Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions internal/cmd/calendar_events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
73 changes: 64 additions & 9 deletions internal/cmd/time_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions internal/cmd/time_range_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading