diff --git a/.surface b/.surface index 342a9027..ae0963fa 100644 --- a/.surface +++ b/.surface @@ -166,6 +166,9 @@ hey event add --start-time hey event add --starts-on hey event add --time-zone hey event add --title +hey event day +hey event day --all +hey event day --limit hey event delete hey event edit hey event edit --all-day @@ -193,6 +196,9 @@ hey event list --calendar hey event list --ends-on hey event list --limit hey event list --starts-on +hey event week +hey event week --all +hey event week --limit hey forward hey forward --bcc hey forward --cc diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 1eb3ce54..ee2e7377 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -95,8 +95,8 @@ which is what `Entries().ListDraftsPage` and `hey draft list --page` exist for. | `/calendar/habits.json` | POST | SDK `Habits().Create` | `hey habit create`, Calendar TUI `a` | covered | | `/calendar/habits/{id}.json` | PATCH | SDK `Habits().Update` | `hey habit edit `, Calendar TUI `e` | covered | | `/calendar/habits/{id}.json` | DELETE | SDK `Habits().Delete` | `hey habit delete `, Calendar TUI `x` | covered | -| `/calendar/days/{date}.json` | GET | SDK `CalendarPeriods().Day` | Calendar TUI day view | covered | -| `/calendar/weeks/{date}.json` | GET | SDK `CalendarPeriods().Week` | `hey habit list`, Calendar TUI week view | covered: a period expands a recurrence into its occurrences and is the only read that carries habits, which a calendar's recordings listing does not | +| `/calendar/days/{date}.json` | GET | SDK `CalendarPeriods().Day` | `hey event day [date]`, Calendar TUI day view | covered | +| `/calendar/weeks/{date}.json` | GET | SDK `CalendarPeriods().Week` | `hey event week [date]`, `hey habit list`, Calendar TUI week view | covered: a period expands a recurrence into its occurrences and is the only read that carries habits, which a calendar's recordings listing does not | | `/calendar/years/{date}.json` | GET | SDK `CalendarPeriods().Year` | Calendar TUI year view | covered | | `/calendar/days/{date}/habits/{id}/completions.json` | POST | SDK `Habits().Complete` | `hey habit complete ` | covered | | `/calendar/days/{date}/habits/{id}/completions.json` | DELETE | SDK `Habits().Uncomplete` | `hey habit uncomplete ` | covered | diff --git a/README.md b/README.md index c12391f3..561c78f4 100644 --- a/README.md +++ b/README.md @@ -320,8 +320,8 @@ rendering when the output is piped, `--ids-only` for one ID per line, and `--cou bare number. `--ids-only` and `--count` need list data, so they work on `hey box list`, `hey box view`, `hey label list`, `hey label view`, `hey collection list`, `hey collection view`, `hey workflow list`, `hey workflow view`, `hey clip list`, `hey snippet list`, `hey draft list`, `hey search`, `hey contact list`, `hey screener list`, `hey screener history`, `hey calendar list`, -`hey event list`, `hey todo list`, `hey habit list`, `hey timetrack list` and -`hey journal list`. +`hey event list`, `hey event day`, `hey event week`, `hey todo list`, `hey habit list`, +`hey timetrack list` and `hey journal list`. The data-only formats print any pagination notice on stderr, so the IDs on stdout stay pipeable. `hey clip list --ids-only` and `--count` cover the newest page only because the @@ -565,6 +565,10 @@ hey event list # every calendar, from today onward hey event list --calendar 123 --starts-on 2026-01-01 --ends-on 2026-01-31 hey event list --count # how many events in the window +hey event day # today as HEY draws it, recurrences expanded +hey event day 2026-09-02 # one day +hey event week 2026-09-02 # the week that day falls in + hey event add "Design review" --starts-on 2026-09-02 --start-time 14:00 --end-time 15:00 hey event add "Sarah's birthday" --starts-on 2026-09-02 # no time, so all day hey event add "Standup" --start-time 09:15 --repeat every_weekday --remind 10m @@ -580,6 +584,14 @@ newest start time, not creation time, so use the ID returned by `hey event add` edit or delete rather than choosing an event by its position in the list. A repeating event lists once as the series it is stored as, not once per day it falls on. +`hey event day` and `hey event week` read a span the way HEY's own views draw it: a +repeating event is expanded into the occurrences that fall inside it, each carrying that +day's own times, an `occurrence_id`, and the id of the series it repeats — which is what +`hey event edit` and `hey event delete` take. A period covers the calendars switched on in +HEY, the same set the app draws, so `day` and `week` take no `--calendar` — only `--limit` +and `--all`. With no date they read the account's own today, whatever zone the machine +runs in. + An event with no `--start-time` is an all-day event, and a `--start-time` with no `--end-time` runs for an hour. Clock times are read in `--time-zone`, which defaults to the machine's own zone; without one HEY would read them as UTC. diff --git a/internal/cmd/events.go b/internal/cmd/events.go index 0ccb682c..e4ad9d8b 100644 --- a/internal/cmd/events.go +++ b/internal/cmd/events.go @@ -29,11 +29,13 @@ func newEventsCommand() *eventsCommand { Use: "event", Short: "Read and manage calendar events", Annotations: map[string]string{ - "agent_notes": "Subcommands: list, add, edit, delete. list reads every calendar unless --calendar names one, and lists a repeating event once as its series rather than once per day. An edit is not a patch on HEY's side: it resends the notes, location, link, attached email, reminders and time zones the event already carries, so notes lose their formatting and a countdown is removed unless --countdown names one again.", + "agent_notes": "Subcommands: list, day, week, add, edit, delete. \"What's on the schedule today?\" is answered by day, not list: day and week read the span as HEY draws it, with a repeating event expanded into the occurrences inside it, over the calendars switched on in HEY. list reads what calendars hold — every calendar unless --calendar names one — and a repeating event is one row, its series, on the day the series began. An edit is not a patch on HEY's side: it resends the notes, location, link, attached email, reminders and time zones the event already carries, so notes lose their formatting and a countdown is removed unless --countdown names one again.", }, } eventsCommand.cmd.AddCommand(newEventsListCommand().cmd) + eventsCommand.cmd.AddCommand(newEventsDayCommand().cmd) + eventsCommand.cmd.AddCommand(newEventsWeekCommand().cmd) eventsCommand.cmd.AddCommand(newEventsAddCommand().cmd) eventsCommand.cmd.AddCommand(newEventsEditCommand().cmd) eventsCommand.cmd.AddCommand(newEventsDeleteCommand().cmd) @@ -55,6 +57,11 @@ func newEventsListCommand() *eventsListCommand { eventsListCommand.cmd = &cobra.Command{ Use: "list", Short: "List calendar events", + Long: `List the events calendars hold over a date window. + +A repeating event is stored once, so it lists once, as its series, on the day the series +began. For the events of a day or a week as HEY draws them — occurrences of a repeating +series expanded into the days they fall on — read 'hey event day' or 'hey event week'.`, Example: ` hey event list hey event list --starts-on 2026-01-01 --ends-on 2026-01-31 hey event list --calendar 123 --limit 5 --json`, @@ -89,60 +96,19 @@ func (c *eventsListCommand) run(cmd *cobra.Command, args []string) error { } notice := output.TruncationNotice(len(events), total) - if writer.IsStyled() { - if len(events) == 0 { - fmt.Fprintf(cmd.OutOrStdout(), "No events %s.\n", window.describe()) - return nil - } - - table := newTable(cmd.OutOrStdout()) - table.addRow([]string{"ID", "Title", "Starts", "Ends", "Calendar"}) - for _, event := range events { - table.addRow([]string{ - fmt.Sprintf("%d", event.Id), event.Title, - eventBoundary(event.StartsAt, event.AllDay), - eventBoundary(event.EndsAt, event.AllDay), - event.Calendar.Name, - }) - } - table.print() - if notice != "" { - fmt.Fprintln(cmd.OutOrStdout(), notice) - } - return nil - } - - return writeOK(events, - output.WithSummary(fmt.Sprintf("%d events (%s)", len(events), window.describe())), - output.WithNotice(notice), - output.WithBreadcrumbs( - output.Breadcrumb{ - Action: "add", - Command: "hey event add '...'", - Description: "Create an event", - }, - output.Breadcrumb{ - Action: "edit", - Command: "hey event edit ", - Description: "Change an event", - }, - output.Breadcrumb{ - Action: "delete", - Command: "hey event delete ", - Description: "Delete an event", - }, - ), - ) + return writeEventRows(cmd, events, window.describe(), notice) } // eventBoundary writes one end of an event: a day for an all-day event, a day and a clock // time for a timed one. An all-day event has no time of day, and printing it midnight reads -// as an event that starts at midnight. +// as an event that starts at midnight. HEY's JSON is always UTC, so a timed boundary +// converts to the reader's clock the way HEY's own views draw it, while an all-day date is +// the day it names and does not shift. func eventBoundary(at time.Time, allDay bool) string { if allDay { return formatDate(at) } - return formatTimestamp(at) + return formatTimestamp(at.Local()) } // add diff --git a/internal/cmd/events_period.go b/internal/cmd/events_period.go new file mode 100644 index 00000000..519c1575 --- /dev/null +++ b/internal/cmd/events_period.go @@ -0,0 +1,241 @@ +package cmd + +import ( + "context" + "fmt" + "sort" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/output" +) + +// eventsPeriodCommand lists events the way HEY draws a day or a week rather than the way a +// calendar stores them. A repeating event is stored once, so `hey event list` answers a +// standing Monday standup on the day the series began; a period expands it into the +// occurrences that fall inside the window, which is what "what's on my schedule today?" +// is asking. `hey habit list` already reads its week this way. +// +// A period is scoped by HEY to the calendars the identity has switched on in the app — the +// same set the app draws — which is why there is no --calendar here: it would read as a +// filter and change nothing. +type eventsPeriodCommand struct { + cmd *cobra.Command + limit int + all bool + + // read fetches the period a date falls in: a day or a week. + read func(ctx context.Context, date string) (*generated.CalendarPeriod, error) + + // describe names the span read, in words that follow "No events" and sit inside the + // summary's parentheses: "on 2026-09-02", "in the week of 2026-09-02". + describe func(date string) string +} + +func newEventsDayCommand() *eventsPeriodCommand { + eventsDayCommand := &eventsPeriodCommand{ + read: func(ctx context.Context, date string) (*generated.CalendarPeriod, error) { + return sdk.CalendarPeriods().Day(ctx, date) + }, + describe: func(date string) string { + if date == periodNow { + return "today" + } + return "on " + date + }, + } + eventsDayCommand.cmd = &cobra.Command{ + Use: "day [date]", + Short: "List the events of one day, as HEY draws it", + Long: `List the events of one day, as HEY's Day View draws it. + +A calendar stores a repeating event once, so 'hey event list' answers a standing Monday +standup on the day the series began and on no other. A day is HEY's own expansion: every +event that falls on it, occurrences of a repeating series included, and nothing from +outside it. + +The day covers the calendars switched on in HEY, the same set the app draws, so there is +no --calendar to narrow it. The ID of an occurrence is its series, which is what 'hey +event edit' and 'hey event delete' take.`, + Example: ` hey event day + hey event day 2026-09-02 + hey event day --json`, + RunE: eventsDayCommand.run, + Args: cobra.MaximumNArgs(1), + } + + eventsDayCommand.registerFlags() + return eventsDayCommand +} + +func newEventsWeekCommand() *eventsPeriodCommand { + eventsWeekCommand := &eventsPeriodCommand{ + read: func(ctx context.Context, date string) (*generated.CalendarPeriod, error) { + return sdk.CalendarPeriods().Week(ctx, date) + }, + describe: func(date string) string { + if date == periodNow { + return "this week" + } + return "in the week of " + date + }, + } + eventsWeekCommand.cmd = &cobra.Command{ + Use: "week [date]", + Short: "List the events of one week, as HEY draws it", + Long: `List the events of the week a date falls in, as HEY's Week View draws it: every event +inside the week, occurrences of a repeating series included. Any day names its week. + +The week covers the calendars switched on in HEY, the same set the app draws, so there is +no --calendar to narrow it. The ID of an occurrence is its series, which is what 'hey +event edit' and 'hey event delete' take.`, + Example: ` hey event week + hey event week 2026-09-02 + hey event week --json`, + RunE: eventsWeekCommand.run, + Args: cobra.MaximumNArgs(1), + } + + eventsWeekCommand.registerFlags() + return eventsWeekCommand +} + +func (c *eventsPeriodCommand) registerFlags() { + c.cmd.Flags().IntVar(&c.limit, "limit", 0, "Maximum number of events to show") + c.cmd.Flags().BoolVar(&c.all, "all", false, "Fetch all results (override --limit)") +} + +func (c *eventsPeriodCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + // With no date the read asks for "now" and HEY resolves today in the account's own + // time zone, so a host in another zone does not fetch yesterday's schedule at midnight. + date := periodNow + if len(args) > 0 { + if _, err := parseDateArg("date", args[0]); err != nil { + return err + } + date = args[0] + } + + ctx := cmd.Context() + period, err := c.read(ctx, date) + if err != nil { + return apierr.FromSDK(err) + } + + events := []generated.Recording{} + if period != nil { + events = filterRecordingsByType(&period.Recordings, recordingTypeEvent) + } + sortEventsByStart(events) + resolveOccurrenceSeries(events) + + total := len(events) + if c.limit > 0 && !c.all && len(events) > c.limit { + events = events[:c.limit] + } + notice := output.TruncationNotice(len(events), total) + + return writeEventRows(cmd, events, c.describe(date), notice) +} + +// periodNow is the date the period reads accept for today: HEY resolves it in the +// account's own time zone, which the CLI process's clock cannot. +const periodNow = "now" + +// resolveOccurrenceSeries gives each row the ID the event verbs take. HEY serves a day of a +// repeating series as a virtual occurrence — no id of its own, the series in parent_id — but +// 'hey event edit' and 'hey event delete' take the series, so the row carries it. +func resolveOccurrenceSeries(events []generated.Recording) { + for i := range events { + if events[i].Id == 0 && events[i].ParentId != 0 { + events[i].Id = events[i].ParentId + } + } +} + +// sortEventsByStart puts a period's events in the order HEY draws the span: day by day, +// each day's all-day band first, then the timed events by clock. HEY serves a period +// grouped by type with no promise about order, and instants alone would not do — an +// all-day event is stamped midnight UTC, so a timed event late the same local evening +// can be the earlier instant even though the app draws it below the band. +func sortEventsByStart(events []generated.Recording) { + sort.SliceStable(events, func(i, j int) bool { + if di, dj := eventDay(events[i]), eventDay(events[j]); di != dj { + return di < dj + } + if events[i].AllDay != events[j].AllDay { + return events[i].AllDay + } + if !events[i].StartsAt.Equal(events[j].StartsAt) { + return events[i].StartsAt.Before(events[j].StartsAt) + } + return events[i].Title < events[j].Title + }) +} + +// eventDay names the day a row sits on, the day HEY draws it: the stamped date for an +// all-day event, the reader's own day for a timed one. HEY's JSON is always UTC, so a +// 23:30Z event belongs to the next day east of Greenwich. +func eventDay(event generated.Recording) string { + if event.AllDay { + return event.StartsAt.UTC().Format(dateLayout) + } + return event.StartsAt.Local().Format(dateLayout) +} + +// writeEventRows renders one listing of events: the table when styled, the JSON envelope +// with the add/edit/delete breadcrumbs otherwise. described names the span read, in +// whatever words the command reads it — a window, a day, a week. +func writeEventRows(cmd *cobra.Command, events []generated.Recording, described, notice string) error { + if writer.IsStyled() { + if len(events) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "No events %s.\n", described) + return nil + } + + table := newTable(cmd.OutOrStdout()) + table.addRow([]string{"ID", "Title", "Starts", "Ends", "Calendar"}) + for _, event := range events { + table.addRow([]string{ + fmt.Sprintf("%d", event.Id), event.Title, + eventBoundary(event.StartsAt, event.AllDay), + eventBoundary(event.EndsAt, event.AllDay), + event.Calendar.Name, + }) + } + table.print() + if notice != "" { + fmt.Fprintln(cmd.OutOrStdout(), notice) + } + return nil + } + + return writeOK(events, + output.WithSummary(fmt.Sprintf("%d events (%s)", len(events), described)), + output.WithNotice(notice), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "add", + Command: "hey event add '...'", + Description: "Create an event", + }, + output.Breadcrumb{ + Action: "edit", + Command: "hey event edit ", + Description: "Change an event", + }, + output.Breadcrumb{ + Action: "delete", + Command: "hey event delete ", + Description: "Delete an event", + }, + ), + ) +} diff --git a/internal/cmd/events_period_test.go b/internal/cmd/events_period_test.go new file mode 100644 index 00000000..fb5209e7 --- /dev/null +++ b/internal/cmd/events_period_test.go @@ -0,0 +1,186 @@ +package cmd + +import ( + "fmt" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" +) + +// A repeating event is one row on a calendar, so `hey event list` answers it on the day the +// series began. A day is HEY's own expansion: the occurrence falls on the day asked for, +// carrying that day's times, and everything that is not an event stays out of the answer. +// HEY serves the occurrence virtual — no id of its own, the series in parent_id — and the +// row resolves that to the series id, which is what edit and delete take. +func TestEventsDayExpandsRecurringEvents(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/calendar/days/2026-09-02.json" { + t.Errorf("request = %s %s, want the day read", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"kind":"day","starts_at":"2026-09-02T00:00:00Z","ends_at":"2026-09-02T23:59:59Z","recordings":{`+ + `"Calendar::Event":[`+ + `{"id":301,"title":"Design review","starts_at":"2026-09-02T14:00:00Z","ends_at":"2026-09-02T15:00:00Z","type":"Calendar::Event","calendar":{"id":9,"name":"Work"}},`+ + `{"title":"Standup","starts_at":"2026-09-02T09:15:00Z","ends_at":"2026-09-02T09:30:00Z","type":"Calendar::Event","recurring":true,"parent_id":204,"occurrence_id":"204_2026-09-02","calendar":{"id":9,"name":"Work"}}`+ + `],`+ + `"Calendar::Habit":[{"id":11,"title":"Morning strength training"}],`+ + `"Calendar::Todo":[{"id":3,"title":"Send notes"}]}}`) + }), "event", "day", "2026-09-02") + if err != nil { + t.Fatalf("execute event day: %v", err) + } + if response.Summary != "2 events (on 2026-09-02)" { + t.Errorf("summary = %q", response.Summary) + } + events, ok := response.Data.([]any) + if !ok || len(events) != 2 { + t.Fatalf("data = %#v, want the two events and nothing else", response.Data) + } + first, ok := events[0].(map[string]any) + if !ok || first["title"] != "Standup" { + t.Errorf("first event = %#v, want the occurrence, in the order the day reads", events[0]) + } + if first["occurrence_id"] != "204_2026-09-02" || first["starts_at"] != "2026-09-02T09:15:00Z" { + t.Errorf("occurrence = %#v, want the day's own times", first) + } + if first["id"] != float64(204) { + t.Errorf("occurrence id = %v, want the series, which is what edit and delete take", first["id"]) + } +} + +func TestEventsWeekReadsTheWeekPeriod(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/calendar/weeks/2026-09-02.json" { + t.Errorf("request = %s %s, want the week read", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"kind":"week","starts_at":"2026-08-31T00:00:00Z","ends_at":"2026-09-06T23:59:59Z","recordings":{`+ + `"Calendar::Event":[`+ + `{"title":"Standup","starts_at":"2026-09-04T09:15:00Z","ends_at":"2026-09-04T09:30:00Z","type":"Calendar::Event","recurring":true,"parent_id":204,"occurrence_id":"204_2026-09-04","calendar":{"id":9,"name":"Work"}},`+ + `{"title":"Standup","starts_at":"2026-09-02T09:15:00Z","ends_at":"2026-09-02T09:30:00Z","type":"Calendar::Event","recurring":true,"parent_id":204,"occurrence_id":"204_2026-09-02","calendar":{"id":9,"name":"Work"}}`+ + `]}}`) + }), "event", "week", "2026-09-02") + if err != nil { + t.Fatalf("execute event week: %v", err) + } + if response.Summary != "2 events (in the week of 2026-09-02)" { + t.Errorf("summary = %q", response.Summary) + } + events, ok := response.Data.([]any) + if !ok || len(events) != 2 { + t.Fatalf("data = %#v, want both occurrences", response.Data) + } + first, ok := events[0].(map[string]any) + if !ok || first["occurrence_id"] != "204_2026-09-02" { + t.Errorf("first event = %#v, want the earlier occurrence first", events[0]) + } +} + +// inLocalZone pins the process's local zone so the suite reads the same wherever it runs. +func inLocalZone(t *testing.T, offsetHours int) { + t.Helper() + local := time.Local + time.Local = time.FixedZone(fmt.Sprintf("UTC%+d", offsetHours), offsetHours*60*60) + t.Cleanup(func() { time.Local = local }) +} + +// The listing reads in the order HEY draws the span: each day's all-day band first, then +// the timed events by clock. HEY's JSON is always UTC, so east of Greenwich a 23:30Z event +// belongs to the next local day — stamped-midnight instants alone would draw it above that +// day's all-day band, and a small --limit could keep the wrong visual row. +func TestEventsDaySortsTheAllDayBandFirst(t *testing.T) { + inLocalZone(t, 2) + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"kind":"day","starts_at":"2026-09-02T00:00:00Z","ends_at":"2026-09-02T23:59:59Z","recordings":{`+ + `"Calendar::Event":[`+ + `{"id":77,"title":"Late night sync","starts_at":"2026-09-01T23:30:00Z","ends_at":"2026-09-02T00:30:00Z","type":"Calendar::Event","calendar":{"id":9,"name":"Work"}},`+ + `{"id":88,"title":"Company holiday","all_day":true,"starts_at":"2026-09-02T00:00:00Z","ends_at":"2026-09-02T00:00:00Z","type":"Calendar::Event","calendar":{"id":9,"name":"Work"}}`+ + `]}}`) + }), "event", "day", "2026-09-02") + if err != nil { + t.Fatalf("execute event day: %v", err) + } + events, ok := response.Data.([]any) + if !ok || len(events) != 2 { + t.Fatalf("data = %#v, want both events", response.Data) + } + first, ok := events[0].(map[string]any) + if !ok || first["title"] != "Company holiday" { + t.Errorf("first event = %#v, want the all-day band on top: both rows are the reader's 2026-09-02", events[0]) + } +} + +// A styled boundary reads on the reader's clock — HEY's JSON is always UTC — while an +// all-day date is the day it names and does not shift. +func TestEventBoundaryDrawsTheReadersClock(t *testing.T) { + inLocalZone(t, 2) + if got := eventBoundary(time.Date(2026, 9, 2, 14, 0, 0, 0, time.UTC), false); got != "2026-09-02T16:00" { + t.Errorf("timed boundary = %q, want the reader's 16:00", got) + } + if got := eventBoundary(time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC), true); got != "2026-09-02" { + t.Errorf("all-day boundary = %q, want the unshifted day", got) + } +} + +// With no date the read asks HEY for "now", which the server resolves in the account's own +// time zone — the CLI process's clock could be a day off either way around midnight. +func TestEventsDayDefaultsToNow(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/calendar/days/now.json" { + t.Errorf("request = %s %s, want the day read for now", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"kind":"day","starts_at":"2026-09-02T00:00:00Z","ends_at":"2026-09-02T23:59:59Z","recordings":{"Calendar::Event":[]}}`) + }), "event", "day") + if err != nil { + t.Fatalf("execute event day: %v", err) + } + if response.Summary != "0 events (today)" { + t.Errorf("summary = %q", response.Summary) + } +} + +func TestEventsDayRejectsABadDate(t *testing.T) { + var requests atomic.Int32 + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + http.Error(w, "unexpected request", http.StatusInternalServerError) + }), "event", "day", "next tuesday") + if err == nil || !strings.Contains(err.Error(), "invalid date") { + t.Fatalf("error = %v, want an invalid date", err) + } + if requests.Load() != 0 { + t.Errorf("requests = %d, want 0", requests.Load()) + } +} + +func TestEventsDayHonorsTheLimit(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"kind":"day","starts_at":"2026-09-02T00:00:00Z","ends_at":"2026-09-02T23:59:59Z","recordings":{`+ + `"Calendar::Event":[`+ + `{"id":301,"title":"Design review","starts_at":"2026-09-02T14:00:00Z","ends_at":"2026-09-02T15:00:00Z","type":"Calendar::Event","calendar":{"id":9,"name":"Work"}},`+ + `{"id":204,"title":"Standup","starts_at":"2026-09-02T09:15:00Z","ends_at":"2026-09-02T09:30:00Z","type":"Calendar::Event","calendar":{"id":9,"name":"Work"}}`+ + `]}}`) + }), "event", "day", "2026-09-02", "--limit", "1") + if err != nil { + t.Fatalf("execute event day: %v", err) + } + events, ok := response.Data.([]any) + if !ok || len(events) != 1 { + t.Fatalf("data = %#v, want the one earliest event", response.Data) + } + if response.Notice == "" { + t.Error("notice = empty, want a truncation notice") + } +} diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index d7520cf4..a66d410a 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -142,8 +142,8 @@ notice on stderr. Both need list data, so they work on `hey box list`, `hey box `hey label list`, `hey label view`, `hey collection list`, `hey collection view`, `hey workflow list`, `hey workflow view`, `hey clip list`, `hey snippet list`, `hey draft list`, `hey search`, `hey contact list`, `hey screener list`, `hey screener history`, `hey calendar list`, -`hey event list`, `hey todo list`, `hey habit list`, `hey timetrack list`, `hey journal list` and -`hey attachment list`. On `hey box view` they count and list its postings, not the box itself. +`hey event list`, `hey event day`, `hey event week`, `hey todo list`, `hey habit list`, +`hey timetrack list`, `hey journal list` and `hey attachment list`. On `hey box view` they count and list its postings, not the box itself. ## Quick Reference @@ -208,6 +208,7 @@ notice on stderr. Both need list data, so they work on `hey box list`, `hey box | Recall a bulk reply | `hey bulk-reply undo ` | | List calendars | `hey calendar list --json` | | List calendar events | `hey event list --json` | +| Today's schedule, recurrences expanded | `hey event day --json` | | Add a calendar event | `hey event add "Design review" --starts-on 2026-09-02 --start-time 14:00` | | List todos | `hey todo list --json` | | Add todo | `hey todo add "Draft the quarterly report"` | @@ -653,6 +654,9 @@ the default end. ```bash hey event list --json # Every calendar, from today onward hey event list --calendar 123 --starts-on 2026-01-01 --ends-on 2026-01-31 --json +hey event day --json # Today as HEY draws it, recurrences expanded +hey event day 2026-09-02 --json # One day +hey event week 2026-09-02 --json # The week that day falls in hey event add "Design review" --starts-on 2026-09-02 --start-time 14:00 --end-time 15:00 hey event add "Sarah's birthday" --starts-on 2026-09-02 # No time given, so all day hey event add "Standup" --start-time 09:15 --repeat every_weekday --remind 10m @@ -664,6 +668,12 @@ Without `--calendar`, `list` reads every calendar and `add` files on the first o accepts events — the personal calendar is in the list HEY serves but refuses them. A repeating event lists once as its series, not once per day. +**"What's on my schedule today?" is `hey event day`, not `list`.** A day or a week is the +span as HEY draws it: a repeating event is expanded into the occurrences inside it, each +carrying that day's own times and an `occurrence_id`, with its `id` still naming the +series that `edit` and `delete` take. The period covers the calendars switched on in HEY, +so `day` and `week` take no `--calendar` — only `--limit` and `--all`. + **Response format:** a flat array of events. Each has `id`, `title`, `starts_at`, `ends_at`, `all_day`, `recurring`, `starts_at_time_zone` and `calendar`; one being edited also carries `description` (the notes, as plain text), `location`, `url`, `attached_entry` and