diff --git a/.agents/skills/gog-calendar/SKILL.md b/.agents/skills/gog-calendar/SKILL.md index 4196667c4..865104921 100644 --- a/.agents/skills/gog-calendar/SKILL.md +++ b/.agents/skills/gog-calendar/SKILL.md @@ -31,6 +31,7 @@ gog --readonly --account user@example.com calendar events --today --json --wrap- | `acl` | List calendar ACL | | `alias` | Manage calendar aliases | | `calendars` | List calendars | +| `changed` | List most recently changed events (including deletions) | | `colors` | Show calendar colors | | `conflicts` | Find busy-time overlaps across calendars | | `create` | Create an event | diff --git a/CHANGELOG.md b/CHANGELOG.md index b18c02d02..644b45a2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## 0.31.0 - 2026-06-24 +- Calendar: add `calendar changed` for listing recently modified events, including cancellations, across one or more calendars. (#875) — thanks @sorenisanerd. - Gmail: preserve HTML fragments from `--signature-file` instead of escaping their markup, without broadening HTML detection for message display or reply quoting. (#879) — thanks @kesslerio. - Docs: honor `--tab` when setting document layout so `page-layout --tab` (and `write --pageless --tab`) target the specified tab instead of always the default tab. Page layout is per-tab; previously these silently no-opped on secondary tabs of multi-tab documents. (#878) — thanks @atmasphere. - Auth: recover from corrupt stored OAuth token payloads by routing only classified decode corruption through the normal re-authentication flow while preserving operational keyring errors. (#872, #874) — thanks @KrasimirKralev. diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 4b112dbc6..7aae4538a 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -86,6 +86,7 @@ Generated from `gog schema --json`. - [`gog calendar (cal) alias set `](commands/gog-calendar-alias-set.md) - Set a calendar alias - [`gog calendar (cal) alias unset `](commands/gog-calendar-alias-unset.md) - Remove a calendar alias - [`gog calendar (cal) calendars [flags]`](commands/gog-calendar-calendars.md) - List calendars + - [`gog calendar (cal) changed [] [flags]`](commands/gog-calendar-changed.md) - List most recently changed events (including deletions) - [`gog calendar (cal) colors`](commands/gog-calendar-colors.md) - Show calendar colors - [`gog calendar (cal) conflicts [flags]`](commands/gog-calendar-conflicts.md) - Find busy-time overlaps across calendars - [`gog calendar (cal) create (add,new) [flags]`](commands/gog-calendar-create.md) - Create an event diff --git a/docs/commands/README.md b/docs/commands/README.md index eae4c0615..022456e40 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ Every `gog` command has a generated docs page. The source of truth is the live CLI schema; run `make docs-commands` after changing command names, flags, help text, aliases, or arguments. -Generated pages: 699. +Generated pages: 700. ## Top-level Commands @@ -138,6 +138,7 @@ Generated pages: 699. - [gog calendar alias set](gog-calendar-alias-set.md) - Set a calendar alias - [gog calendar alias unset](gog-calendar-alias-unset.md) - Remove a calendar alias - [gog calendar calendars](gog-calendar-calendars.md) - List calendars + - [gog calendar changed](gog-calendar-changed.md) - List most recently changed events (including deletions) - [gog calendar colors](gog-calendar-colors.md) - Show calendar colors - [gog calendar conflicts](gog-calendar-conflicts.md) - Find busy-time overlaps across calendars - [gog calendar create](gog-calendar-create.md) - Create an event diff --git a/docs/commands/gog-calendar-changed.md b/docs/commands/gog-calendar-changed.md new file mode 100644 index 000000000..89022c2aa --- /dev/null +++ b/docs/commands/gog-calendar-changed.md @@ -0,0 +1,54 @@ +# `gog calendar changed` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +List most recently changed events (including deletions) + +## Usage + +```bash +gog calendar (cal) changed [] [flags] +``` + +## Parent + +- [gog calendar](gog-calendar.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands | +| `--all` | `bool` | | Fetch from all calendars | +| `--cal` | `[]string` | | Calendar ID or name (can be repeated) | +| `--calendars` | `string` | | Comma-separated calendar IDs, names, or indices from 'calendar calendars' | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `--fail-empty`
`--non-empty`
`--require-results` | `bool` | | Exit with code 3 if no results | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--location` | `bool` | | Include event LOCATION column in table output | +| `--max`
`--limit` | `int64` | 10 | Max results | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--since` | `string` | | Lower bound for last-modification time (RFC3339, date, or Go duration: 24h, 168h). Default: 720h (30 days). | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--weekday` | `bool` | | Include start/end day-of-week columns | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog calendar](gog-calendar.md) +- [Command index](README.md) diff --git a/docs/commands/gog-calendar.md b/docs/commands/gog-calendar.md index a49a36644..6ea6dfff3 100644 --- a/docs/commands/gog-calendar.md +++ b/docs/commands/gog-calendar.md @@ -19,6 +19,7 @@ gog calendar (cal) [flags] - [gog calendar acl](gog-calendar-acl.md) - List calendar ACL - [gog calendar alias](gog-calendar-alias.md) - Manage calendar aliases - [gog calendar calendars](gog-calendar-calendars.md) - List calendars +- [gog calendar changed](gog-calendar-changed.md) - List most recently changed events (including deletions) - [gog calendar colors](gog-calendar-colors.md) - Show calendar colors - [gog calendar conflicts](gog-calendar-conflicts.md) - Find busy-time overlaps across calendars - [gog calendar create](gog-calendar-create.md) - Create an event diff --git a/internal/cmd/calendar.go b/internal/cmd/calendar.go index ac061742d..9b35a3863 100644 --- a/internal/cmd/calendar.go +++ b/internal/cmd/calendar.go @@ -20,6 +20,7 @@ type CalendarCmd struct { ProposeTime CalendarProposeTimeCmd `cmd:"" name:"propose-time" help:"Generate URL to propose a new meeting time (browser-only feature)"` Colors CalendarColorsCmd `cmd:"" name:"colors" help:"Show calendar colors"` Conflicts CalendarConflictsCmd `cmd:"" name:"conflicts" help:"Find busy-time overlaps across calendars"` + Changed CalendarChangedCmd `cmd:"" name:"changed" help:"List most recently changed events (including deletions)"` Search CalendarSearchCmd `cmd:"" name:"search" aliases:"find,query" help:"Search events"` Time CalendarTimeCmd `cmd:"" name:"time" help:"Show server time"` Users CalendarUsersCmd `cmd:"" name:"users" help:"List workspace users (use their email as calendar ID)"` diff --git a/internal/cmd/calendar_changed.go b/internal/cmd/calendar_changed.go new file mode 100644 index 000000000..401f9fd29 --- /dev/null +++ b/internal/cmd/calendar_changed.go @@ -0,0 +1,265 @@ +package cmd + +import ( + "context" + "sort" + "strings" + "time" + + "google.golang.org/api/calendar/v3" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/timeparse" + "github.com/steipete/gogcli/internal/ui" +) + +type CalendarChangedCmd struct { + CalendarID string `arg:"" name:"calendarId" optional:"" help:"Calendar ID (default: primary)"` + Cal []string `name:"cal" help:"Calendar ID or name (can be repeated)"` + Calendars string `name:"calendars" help:"Comma-separated calendar IDs, names, or indices from 'calendar calendars'"` + Since string `name:"since" help:"Lower bound for last-modification time (RFC3339, date, or Go duration: 24h, 168h). Default: 720h (30 days)."` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"10"` + All bool `name:"all" help:"Fetch from all calendars"` + FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` + Weekday bool `name:"weekday" help:"Include start/end day-of-week columns"` + Location bool `name:"location" help:"Include event LOCATION column in table output"` +} + +func (c *CalendarChangedCmd) Run(ctx context.Context, flags *RootFlags) error { + if c.Max <= 0 { + return usage("max must be > 0") + } + + since, err := c.resolveSince() + if err != nil { + return err + } + + _, svc, err := requireCalendarService(ctx, flags) + if err != nil { + return err + } + store, err := commandConfigStore(ctx) + if err != nil { + return err + } + + calendarID := strings.TrimSpace(c.CalendarID) + calInputs := append([]string{}, c.Cal...) + if strings.TrimSpace(c.Calendars) != "" { + calInputs = append(calInputs, splitCSV(c.Calendars)...) + } + if c.All && (calendarID != "" || len(calInputs) > 0) { + return usage("calendarId or --cal/--calendars not allowed with --all flag") + } + if calendarID != "" && len(calInputs) > 0 { + return usage("calendarId not allowed with --cal/--calendars") + } + + sinceRFC3339 := since.UTC().Format(time.RFC3339) + + switch { + case c.All: + cals, listErr := listCalendarList(ctx, svc) + if listErr != nil { + return listErr + } + ids := make([]string, 0, len(cals)) + for _, cal := range cals { + if cal != nil && strings.TrimSpace(cal.Id) != "" { + ids = append(ids, cal.Id) + } + } + return c.listChangedMulti(ctx, svc, ids, sinceRFC3339, calendarTimezoneHints(cals)) + case len(calInputs) > 0: + ids, resolveErr := resolveCalendarIDs(ctx, store, svc, calInputs) + if resolveErr != nil { + return resolveErr + } + if len(ids) == 0 { + return usage("no calendars specified") + } + return c.listChangedMulti(ctx, svc, ids, sinceRFC3339, nil) + default: + calendarID, err = resolveCalendarSelector(ctx, store, svc, calendarID, true) + if err != nil { + return err + } + return c.listChangedSingle(ctx, svc, calendarID, sinceRFC3339) + } +} + +func (c *CalendarChangedCmd) resolveSince() (time.Time, error) { + if strings.TrimSpace(c.Since) == "" { + return time.Now().Add(-30 * 24 * time.Hour), nil + } + result, err := timeparse.ParseSince(c.Since, time.Now(), time.UTC) + if err != nil { + return time.Time{}, usagef("invalid --since value: %v", err) + } + return result.Time, nil +} + +func (c *CalendarChangedCmd) listChangedSingle(ctx context.Context, svc *calendar.Service, calendarID, since string) error { + calendarTimezone, loc := calendarDisplayTimezone(ctx, svc, calendarID, nil) + + items, err := fetchChangedEvents(ctx, svc, calendarID, since) + if err != nil { + return err + } + + events := make([]*eventWithCalendar, 0, len(items)) + for _, item := range items { + redactCalendarEventForOutput(ctx, item) + events = append(events, wrapEventWithCalendar(item, "", calendarTimezone, loc)) + } + + sortByUpdatedDesc(events) + if int64(len(events)) > c.Max { + events = events[:c.Max] + } + + return c.writeOutput(ctx, events, since, false) +} + +func (c *CalendarChangedCmd) listChangedMulti(ctx context.Context, svc *calendar.Service, calendarIDs []string, since string, hints map[string]calendarTimezoneHint) error { + u := ui.FromContext(ctx) + all := make([]*eventWithCalendar, 0) + for _, calID := range calendarIDs { + calID = strings.TrimSpace(calID) + if calID == "" { + continue + } + calendarTimezone, loc := calendarDisplayTimezone(ctx, svc, calID, hints) + items, err := fetchChangedEvents(ctx, svc, calID, since) + if err != nil { + u.Err().Linef("calendar %s: %v", calID, err) + continue + } + for _, item := range items { + redactCalendarEventForOutput(ctx, item) + all = append(all, wrapEventWithCalendar(item, calID, calendarTimezone, loc)) + } + } + + sortByUpdatedDesc(all) + if int64(len(all)) > c.Max { + all = all[:c.Max] + } + + return c.writeOutput(ctx, all, since, true) +} + +func fetchChangedEvents(ctx context.Context, svc *calendar.Service, calendarID, since string) ([]*calendar.Event, error) { + fetch := func(pageToken string) ([]*calendar.Event, string, error) { + // Calendar always returns entries deleted since updatedMin. Request them + // explicitly too: deletions are changes and belong in this command's output. + call := svc.Events.List(calendarID). + UpdatedMin(since). + ShowDeleted(true). + OrderBy("updated"). + MaxResults(250). + Context(ctx) + if pageToken != "" { + call = call.PageToken(pageToken) + } + resp, err := call.Do() + if err != nil { + return nil, "", err + } + return resp.Items, resp.NextPageToken, nil + } + items, err := collectAllPages("", fetch) + return items, err +} + +func sortByUpdatedDesc(events []*eventWithCalendar) { + sort.SliceStable(events, func(i, j int) bool { + a := calendarEvent(events[i]).Updated + b := calendarEvent(events[j]).Updated + return a > b + }) +} + +func (c *CalendarChangedCmd) writeOutput(ctx context.Context, events []*eventWithCalendar, since string, includeCalendar bool) error { + u := ui.FromContext(ctx) + if outfmt.IsJSON(ctx) { + jsonItems := make([]any, 0, len(events)) + for _, e := range events { + jsonItems = append(jsonItems, e) + } + if err := outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{ + "events": jsonItems, + "since": since, + }); err != nil { + return err + } + if len(events) == 0 { + return failEmptyExit(c.FailEmpty) + } + return nil + } + + if len(events) == 0 { + u.Err().Println("No events") + return failEmptyExit(c.FailEmpty) + } + return outfmt.WriteTable(ctx, stdoutWriter(ctx), compactCalendarRows(events), changedEventColumns(includeCalendar, c.Weekday, c.Location)) +} + +func changedEventColumns(includeCalendar, showWeekday, showLocation bool) []outfmt.Column[*eventWithCalendar] { + columns := make([]outfmt.Column[*eventWithCalendar], 0, 8) + columns = append(columns, outfmt.Column[*eventWithCalendar]{ + Header: "UPDATED", + Value: func(e *eventWithCalendar) string { return calendarEvent(e).Updated }, + }) + if includeCalendar { + columns = append(columns, outfmt.Column[*eventWithCalendar]{ + Header: "CALENDAR", + Value: func(e *eventWithCalendar) string { return e.CalendarID }, + }) + } + columns = append(columns, + outfmt.Column[*eventWithCalendar]{ + Header: "ID", + Value: func(e *eventWithCalendar) string { return calendarEvent(e).Id }, + }, + outfmt.Column[*eventWithCalendar]{ + Header: "START", + Value: eventDisplayStart, + }, + ) + if showWeekday { + columns = append(columns, outfmt.Column[*eventWithCalendar]{ + Header: "START_DOW", + Value: func(e *eventWithCalendar) string { + startDay, _ := calendarEventWeekdays(e, includeCalendar) + return startDay + }, + }) + } + columns = append(columns, outfmt.Column[*eventWithCalendar]{ + Header: "END", + Value: eventDisplayEnd, + }) + if showWeekday { + columns = append(columns, outfmt.Column[*eventWithCalendar]{ + Header: "END_DOW", + Value: func(e *eventWithCalendar) string { + _, endDay := calendarEventWeekdays(e, includeCalendar) + return endDay + }, + }) + } + columns = append(columns, outfmt.Column[*eventWithCalendar]{ + Header: "SUMMARY", + Value: func(e *eventWithCalendar) string { return calendarEvent(e).Summary }, + }) + if showLocation { + columns = append(columns, outfmt.Column[*eventWithCalendar]{ + Header: "LOCATION", + Value: eventDisplayLocation, + }) + } + return columns +} diff --git a/internal/cmd/calendar_changed_test.go b/internal/cmd/calendar_changed_test.go new file mode 100644 index 000000000..23b476178 --- /dev/null +++ b/internal/cmd/calendar_changed_test.go @@ -0,0 +1,219 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +func TestCalendarChanged_JSON(t *testing.T) { + var showDeleted string + svc, closeServer := newCalendarServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/calendars/primary/events") && r.Method == http.MethodGet { + showDeleted = r.URL.Query().Get("showDeleted") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": "e1", + "summary": "Older event", + "updated": "2026-06-10T08:00:00Z", + "start": map[string]any{"dateTime": "2026-06-15T10:00:00Z"}, + "end": map[string]any{"dateTime": "2026-06-15T11:00:00Z"}, + }, + { + "id": "e2", + "summary": "Newer event", + "updated": "2026-06-12T09:00:00Z", + "start": map[string]any{"dateTime": "2026-06-20T10:00:00Z"}, + "end": map[string]any{"dateTime": "2026-06-20T11:00:00Z"}, + }, + { + "id": "e3", + "summary": "Deleted event", + "status": "cancelled", + "updated": "2026-06-13T09:00:00Z", + }, + }, + }) + return + } + // Calendar timezone lookup + if strings.Contains(r.URL.Path, "/calendars/primary") && r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "primary", "timeZone": "UTC"}) + return + } + http.NotFound(w, r) + })) + defer closeServer() + + var output bytes.Buffer + ctx := newCmdRuntimeJSONOutputContext(t, &output, io.Discard) + cmd := &CalendarChangedCmd{Max: 10, Since: "720h"} + if err := cmd.listChangedSingle(ctx, svc, "primary", "2026-05-14T00:00:00Z"); err != nil { + t.Fatalf("listChangedSingle: %v", err) + } + + var parsed struct { + Events []map[string]any `json:"events"` + Since string `json:"since"` + } + if err := json.Unmarshal(output.Bytes(), &parsed); err != nil { + t.Fatalf("json parse: %v", err) + } + if showDeleted != "true" { + t.Fatalf("showDeleted = %q, want true", showDeleted) + } + if len(parsed.Events) != 3 { + t.Fatalf("expected 3 events, got %d", len(parsed.Events)) + } + // Most recently updated event should come first (descending order). + if parsed.Events[0]["id"] != "e3" { + t.Errorf("expected deleted e3 first (more recent updated), got %v", parsed.Events[0]["id"]) + } + if parsed.Events[1]["id"] != "e2" || parsed.Events[2]["id"] != "e1" { + t.Errorf("expected remaining events in descending order, got %v then %v", parsed.Events[1]["id"], parsed.Events[2]["id"]) + } + if parsed.Since == "" { + t.Error("expected since field in JSON output") + } +} + +func TestCalendarChanged_Table(t *testing.T) { + svc, closeServer := newCalendarServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/calendars/cal1/events") && r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": "ev1", + "summary": "Meeting", + "updated": "2026-06-13T10:00:00Z", + "start": map[string]any{"dateTime": "2026-06-14T09:00:00Z"}, + "end": map[string]any{"dateTime": "2026-06-14T10:00:00Z"}, + }, + }, + }) + return + } + if strings.Contains(r.URL.Path, "/calendars/cal1") && r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "cal1", "timeZone": "UTC"}) + return + } + http.NotFound(w, r) + })) + defer closeServer() + + var output bytes.Buffer + ctx := newCmdRuntimeOutputContext(t, &output, io.Discard) + cmd := &CalendarChangedCmd{Max: 10} + if err := cmd.listChangedSingle(ctx, svc, "cal1", "2026-05-14T00:00:00Z"); err != nil { + t.Fatalf("listChangedSingle: %v", err) + } + + out := output.String() + if !strings.Contains(out, "UPDATED") { + t.Errorf("table output missing UPDATED column header; got:\n%s", out) + } + if !strings.Contains(out, "Meeting") { + t.Errorf("table output missing event summary; got:\n%s", out) + } + if !strings.Contains(out, "ev1") { + t.Errorf("table output missing event ID; got:\n%s", out) + } +} + +func TestCalendarChanged_MaxLimitsResults(t *testing.T) { + svc, closeServer := newCalendarServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/calendars/primary/events") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"id": "e1", "summary": "A", "updated": "2026-06-10T01:00:00Z", "start": map[string]any{"dateTime": "2026-06-15T10:00:00Z"}, "end": map[string]any{"dateTime": "2026-06-15T11:00:00Z"}}, + {"id": "e2", "summary": "B", "updated": "2026-06-10T02:00:00Z", "start": map[string]any{"dateTime": "2026-06-15T10:00:00Z"}, "end": map[string]any{"dateTime": "2026-06-15T11:00:00Z"}}, + {"id": "e3", "summary": "C", "updated": "2026-06-10T03:00:00Z", "start": map[string]any{"dateTime": "2026-06-15T10:00:00Z"}, "end": map[string]any{"dateTime": "2026-06-15T11:00:00Z"}}, + }, + }) + return + } + if strings.Contains(r.URL.Path, "/calendars/primary") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "primary", "timeZone": "UTC"}) + return + } + http.NotFound(w, r) + })) + defer closeServer() + + var output bytes.Buffer + ctx := newCmdRuntimeJSONOutputContext(t, &output, io.Discard) + cmd := &CalendarChangedCmd{Max: 2} + if err := cmd.listChangedSingle(ctx, svc, "primary", "2026-05-01T00:00:00Z"); err != nil { + t.Fatalf("listChangedSingle: %v", err) + } + + var parsed struct { + Events []map[string]any `json:"events"` + } + if err := json.Unmarshal(output.Bytes(), &parsed); err != nil { + t.Fatalf("json parse: %v", err) + } + if len(parsed.Events) != 2 { + t.Fatalf("expected 2 events (max), got %d", len(parsed.Events)) + } + // Should be the 2 most recently updated (e3, e2 in that order). + if parsed.Events[0]["id"] != "e3" { + t.Errorf("expected e3 first, got %v", parsed.Events[0]["id"]) + } +} + +func TestCalendarChanged_DefaultSince(t *testing.T) { + var capturedUpdatedMin string + svc, closeServer := newCalendarServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/calendars/primary/events") { + capturedUpdatedMin, _ = url.QueryUnescape(r.URL.Query().Get("updatedMin")) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}}) + return + } + if strings.Contains(r.URL.Path, "/calendars/primary") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "primary", "timeZone": "UTC"}) + return + } + http.NotFound(w, r) + })) + defer closeServer() + + before := time.Now() + cmd := &CalendarChangedCmd{Max: 10} + since, err := cmd.resolveSince() + if err != nil { + t.Fatalf("resolveSince: %v", err) + } + + ctx := newCmdRuntimeJSONOutputContext(t, io.Discard, io.Discard) + _ = cmd.listChangedSingle(ctx, svc, "primary", since.UTC().Format(time.RFC3339)) + + if capturedUpdatedMin == "" { + t.Fatal("updatedMin query param not sent to API") + } + parsed, err := time.Parse(time.RFC3339, capturedUpdatedMin) + if err != nil { + t.Fatalf("could not parse captured updatedMin %q: %v", capturedUpdatedMin, err) + } + + // Default should be ~30 days ago. Allow 2s slack for RFC3339 second-truncation and test execution time. + expectedCenter := before.Add(-30 * 24 * time.Hour).Truncate(time.Second) + diff := parsed.Sub(expectedCenter) + if diff < -2*time.Second || diff > 2*time.Second { + t.Errorf("default since %v not within 2s of expected 30-day window center %v (diff %v)", parsed, expectedCenter, diff) + } +}