From b174418f215ff402632216f2fabc0ecda64066d9 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 31 Aug 2026 15:10:00 -0700 Subject: [PATCH 1/4] List events as HEY draws them with hey event day and week MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repeating event is stored once, so hey event list answers a standing Monday standup on the day the series began and on no other day — an agent asking "what's on my schedule today?" never sees it (#158). HEY already has the answer: a calendar period expands recurrences into the occurrences inside its window and filters to it. The SDK serves it as CalendarPeriods().Day and Week, the TUI's views are built on it, and hey habit list reads its week this way. The CLI had just never offered the period read as a command. hey event day [date] and hey event week [date] read the day and the week as HEY draws them, filtered to events, sorted by start. A period is scoped to the calendars switched on in HEY rather than to a --calendar id, so the period read is its own command instead of a --today flag on list, where it would silently switch data sources and ignore --calendar. --- .surface | 6 + API-COVERAGE.md | 4 +- internal/cmd/events.go | 54 ++------ internal/cmd/events_period.go | 196 +++++++++++++++++++++++++++++ internal/cmd/events_period_test.go | 112 +++++++++++++++++ skills/hey/SKILL.md | 14 ++- 6 files changed, 337 insertions(+), 49 deletions(-) create mode 100644 internal/cmd/events_period.go create mode 100644 internal/cmd/events_period_test.go 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/internal/cmd/events.go b/internal/cmd/events.go index 0ccb682c..54067336 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,50 +96,7 @@ 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 diff --git a/internal/cmd/events_period.go b/internal/cmd/events_period.go new file mode 100644 index 00000000..eb41d81f --- /dev/null +++ b/internal/cmd/events_period.go @@ -0,0 +1,196 @@ +package cmd + +import ( + "context" + "fmt" + "sort" + "time" + + "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 { 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 { 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 + } + + date := time.Now().Format(dateLayout) + 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) + + 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) +} + +// sortEventsByStart puts a period's events in the order the span reads, top to bottom in +// time whichever calendar each row came from. HEY serves a period grouped by type, and the +// group carries no promise about order. +func sortEventsByStart(events []generated.Recording) { + sort.SliceStable(events, func(i, j int) bool { + if events[i].StartsAt.Equal(events[j].StartsAt) { + return events[i].Title < events[j].Title + } + return events[i].StartsAt.Before(events[j].StartsAt) + }) +} + +// 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..7dc3b5e3 --- /dev/null +++ b/internal/cmd/events_period_test.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "io" + "net/http" + "strings" + "sync/atomic" + "testing" +) + +// 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. +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"}},`+ + `{"id":204,"title":"Standup","starts_at":"2026-09-02T09:15:00Z","ends_at":"2026-09-02T09:30:00Z","type":"Calendar::Event","recurring":true,"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) + } +} + +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":[`+ + `{"id":204,"title":"Standup","starts_at":"2026-09-04T09:15:00Z","ends_at":"2026-09-04T09:30:00Z","type":"Calendar::Event","recurring":true,"occurrence_id":"204-2026-09-04","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","recurring":true,"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]) + } +} + +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 From 987c0dbc34a58c9056e5497bf49721570c2f0dc6 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 31 Aug 2026 15:22:16 -0700 Subject: [PATCH 2/4] Ask HEY for today by name and hand occurrences their series id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With no date, day and week read /calendar/{days,weeks}/now.json so HEY resolves today in the account's own time zone rather than the CLI host's, which could be a day off around midnight. HEY serves a day of a repeating series as a virtual occurrence — no id of its own, the series in parent_id, occurrence_id underscored — so the row resolves its id to the series, the id event edit and delete take, in the table, the JSON, and --ids-only alike. Fixtures now model the payload as served. README gains day/week in the list-output inventory and the Events section. --- README.md | 16 ++++++++++++-- internal/cmd/events_period.go | 35 ++++++++++++++++++++++++++---- internal/cmd/events_period_test.go | 35 +++++++++++++++++++++++++----- 3 files changed, 75 insertions(+), 11 deletions(-) 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_period.go b/internal/cmd/events_period.go index eb41d81f..a888cf02 100644 --- a/internal/cmd/events_period.go +++ b/internal/cmd/events_period.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "sort" - "time" "github.com/spf13/cobra" @@ -41,7 +40,12 @@ func newEventsDayCommand() *eventsPeriodCommand { read: func(ctx context.Context, date string) (*generated.CalendarPeriod, error) { return sdk.CalendarPeriods().Day(ctx, date) }, - describe: func(date string) string { return "on " + date }, + describe: func(date string) string { + if date == periodNow { + return "today" + } + return "on " + date + }, } eventsDayCommand.cmd = &cobra.Command{ Use: "day [date]", @@ -72,7 +76,12 @@ func newEventsWeekCommand() *eventsPeriodCommand { read: func(ctx context.Context, date string) (*generated.CalendarPeriod, error) { return sdk.CalendarPeriods().Week(ctx, date) }, - describe: func(date string) string { return "in the week of " + 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]", @@ -104,7 +113,9 @@ func (c *eventsPeriodCommand) run(cmd *cobra.Command, args []string) error { return err } - date := time.Now().Format(dateLayout) + // 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 @@ -123,6 +134,7 @@ func (c *eventsPeriodCommand) run(cmd *cobra.Command, args []string) error { events = filterRecordingsByType(&period.Recordings, recordingTypeEvent) } sortEventsByStart(events) + resolveOccurrenceSeries(events) total := len(events) if c.limit > 0 && !c.all && len(events) > c.limit { @@ -133,6 +145,21 @@ func (c *eventsPeriodCommand) run(cmd *cobra.Command, args []string) error { 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 the span reads, top to bottom in // time whichever calendar each row came from. HEY serves a period grouped by type, and the // group carries no promise about order. diff --git a/internal/cmd/events_period_test.go b/internal/cmd/events_period_test.go index 7dc3b5e3..9499f758 100644 --- a/internal/cmd/events_period_test.go +++ b/internal/cmd/events_period_test.go @@ -11,6 +11,8 @@ import ( // 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" { @@ -22,7 +24,7 @@ func TestEventsDayExpandsRecurringEvents(t *testing.T) { _, _ = 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","recurring":true,"occurrence_id":"204-2026-09-02","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"}]}}`) @@ -41,9 +43,12 @@ func TestEventsDayExpandsRecurringEvents(t *testing.T) { 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" { + 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) { @@ -56,8 +61,8 @@ func TestEventsWeekReadsTheWeekPeriod(t *testing.T) { 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":[`+ - `{"id":204,"title":"Standup","starts_at":"2026-09-04T09:15:00Z","ends_at":"2026-09-04T09:30:00Z","type":"Calendar::Event","recurring":true,"occurrence_id":"204-2026-09-04","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","recurring":true,"occurrence_id":"204-2026-09-02","calendar":{"id":9,"name":"Work"}}`+ + `{"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 { @@ -71,11 +76,31 @@ func TestEventsWeekReadsTheWeekPeriod(t *testing.T) { 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" { + if !ok || first["occurrence_id"] != "204_2026-09-02" { t.Errorf("first event = %#v, want the earlier occurrence first", events[0]) } } +// 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) { From e4278b69970388f10d89bea952e9b2c03854e451 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 31 Aug 2026 15:29:58 -0700 Subject: [PATCH 3/4] Draw each day's all-day band first, whatever instant it is stamped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An all-day event is stamped midnight UTC, so a timed 00:30+10:00 the same day is the earlier absolute instant — sorted by instants alone it would lead the listing, and a small --limit could drop the band for a later visual row. Sort day by day, the all-day band first within each, then the timed events by clock, the order HEY's own views draw. --- internal/cmd/events_period.go | 29 +++++++++++++++++++++++------ internal/cmd/events_period_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/internal/cmd/events_period.go b/internal/cmd/events_period.go index a888cf02..12f42b3c 100644 --- a/internal/cmd/events_period.go +++ b/internal/cmd/events_period.go @@ -160,18 +160,35 @@ func resolveOccurrenceSeries(events []generated.Recording) { } } -// sortEventsByStart puts a period's events in the order the span reads, top to bottom in -// time whichever calendar each row came from. HEY serves a period grouped by type, and the -// group carries no promise about order. +// 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 00:30+10:00 the same day is 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 events[i].StartsAt.Equal(events[j].StartsAt) { - return events[i].Title < events[j].Title + if di, dj := eventDay(events[i]), eventDay(events[j]); di != dj { + return di < dj } - return events[i].StartsAt.Before(events[j].StartsAt) + 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 stamped date for an all-day event, the start's +// own local day for a timed one. +func eventDay(event generated.Recording) string { + if event.AllDay { + return event.StartsAt.UTC().Format(dateLayout) + } + return event.StartsAt.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. diff --git a/internal/cmd/events_period_test.go b/internal/cmd/events_period_test.go index 9499f758..6857d5b7 100644 --- a/internal/cmd/events_period_test.go +++ b/internal/cmd/events_period_test.go @@ -81,6 +81,32 @@ func TestEventsWeekReadsTheWeekPeriod(t *testing.T) { } } +// The listing reads in the order HEY draws the span: each day's all-day band first, then +// the timed events by clock. An all-day event is stamped midnight UTC, so a timed +// 00:30+10:00 the same day is the earlier absolute instant — and would wrongly lead, or +// push the band off a --limit, if instants alone decided. +func TestEventsDaySortsTheAllDayBandFirst(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":77,"title":"Early swim","starts_at":"2026-09-02T00:30:00+10:00","ends_at":"2026-09-02T01:30:00+10:00","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", events[0]) + } +} + // 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) { From acafb18285a3c2eaa357d0b69a012fd8a149f4d2 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 31 Aug 2026 15:45:37 -0700 Subject: [PATCH 4/4] Read event listings on the reader's clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HEY's JSON is always UTC, so a styled boundary printed the UTC wall clock — 14:00Z read as 14:00 in Berlin where HEY's views draw 16:00 — and a timed event late the local evening was grouped onto the UTC day, above the next day's all-day band. Timed boundaries now convert .Local() the way the TUI and timetrack listings already do, and a timed event sorts on the reader's own day; all-day dates stay the day they name, and the JSON keeps the timestamps as HEY served them. Zone-pinned tests cover the boundary clock and the band ordering. --- internal/cmd/events.go | 6 ++++-- internal/cmd/events_period.go | 11 +++++----- internal/cmd/events_period_test.go | 33 +++++++++++++++++++++++++----- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/internal/cmd/events.go b/internal/cmd/events.go index 54067336..e4ad9d8b 100644 --- a/internal/cmd/events.go +++ b/internal/cmd/events.go @@ -101,12 +101,14 @@ func (c *eventsListCommand) run(cmd *cobra.Command, args []string) error { // 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 index 12f42b3c..519c1575 100644 --- a/internal/cmd/events_period.go +++ b/internal/cmd/events_period.go @@ -163,8 +163,8 @@ func resolveOccurrenceSeries(events []generated.Recording) { // 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 00:30+10:00 the same day is the -// earlier instant even though the app draws it below the band. +// 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 { @@ -180,13 +180,14 @@ func sortEventsByStart(events []generated.Recording) { }) } -// eventDay names the day a row sits on: the stamped date for an all-day event, the start's -// own local day for a timed one. +// 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.Format(dateLayout) + return event.StartsAt.Local().Format(dateLayout) } // writeEventRows renders one listing of events: the table when styled, the JSON envelope diff --git a/internal/cmd/events_period_test.go b/internal/cmd/events_period_test.go index 6857d5b7..fb5209e7 100644 --- a/internal/cmd/events_period_test.go +++ b/internal/cmd/events_period_test.go @@ -1,11 +1,13 @@ 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 @@ -81,16 +83,25 @@ func TestEventsWeekReadsTheWeekPeriod(t *testing.T) { } } +// 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. An all-day event is stamped midnight UTC, so a timed -// 00:30+10:00 the same day is the earlier absolute instant — and would wrongly lead, or -// push the band off a --limit, if instants alone decided. +// 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":"Early swim","starts_at":"2026-09-02T00:30:00+10:00","ends_at":"2026-09-02T01:30:00+10:00","type":"Calendar::Event","calendar":{"id":9,"name":"Work"}},`+ + `{"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") @@ -103,7 +114,19 @@ func TestEventsDaySortsTheAllDayBandFirst(t *testing.T) { } 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", events[0]) + 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) } }