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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ hey event day --limit
hey event delete
hey event edit
hey event edit --all-day
hey event edit --allow-plain-notes
hey event edit --apply-to
hey event edit --calendar
hey event edit --circle
hey event edit --countdown
Expand All @@ -195,6 +197,7 @@ hey event edit --invite
hey event edit --link
hey event edit --location
hey event edit --notes
hey event edit --occurrence
hey event edit --remind
hey event edit --repeat
hey event edit --repeat-times
Expand Down
4 changes: 2 additions & 2 deletions API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ which is what `Entries().ListDraftsPage` and `hey draft list --page` exist for.
| `/contacts/{id}/note.json` | PATCH | SDK `Contacts().SetNote` | `hey contact note set`, Contacts TUI | covered |
| `/contacts/{id}/note.json` | DELETE | SDK `Contacts().DeleteNote` | `hey contact note delete`, Contacts TUI | covered |
| `/calendars.json` | GET | SDK `Calendars().List` | `hey calendar list` | covered |
| `/calendars/{id}/recordings.json` | GET | SDK `Calendars().GetRecordings` | `hey event list`, `hey event edit <id>` (reading the event back), `hey todo list`, `hey journal list` | covered |
| `/calendars/{id}/recordings.json` | GET | SDK `Calendars().GetRecordings` | `hey event list`, `hey event edit <id>` (reading the event back; with `--occurrence`, the occurrence's day and then the series' first day for its `Calendar::Countdown`), `hey todo list`, `hey journal list` | covered |
| `/topics/{id}/entries.json` | GET | SDK `Topics().GetEntries` | `hey thread read <id>`, `hey attachment list <topic-id>` | covered, but see the paging note below |
| `/topics/{id}/publication` | POST | SDK `Publications().Create` | `hey share <thread-id>` | covered |
| `/topics/{id}/publication.json` | GET | SDK `Publications().Create` readback | `hey share <thread-id>` | covered |
Expand Down Expand Up @@ -89,7 +89,7 @@ which is what `Entries().ListDraftsPage` and `hey draft list --page` exist for.
| `/calendar/events.json` | POST | SDK `CalendarEvents().Create` | `hey event add`, Calendar TUI `a` | covered |
| `/calendar/events/{id}.json` | PATCH | SDK `CalendarEvents().Update` | `hey event edit <id>`, Calendar TUI `e` | covered: a write replaces rather than patches, so the caller reads the event and sends back what it keeps |
| `/calendar/events/{id}` | DELETE | SDK `CalendarEvents().Delete` | `hey event delete <id>`, Calendar TUI `x` | covered |
| `/calendar/events/{id}/occurrences/{date}.json` | PATCH | SDK `CalendarEvents().UpdateOccurrence` | Calendar TUI `e` on one day of a repeating event | covered; no CLI equivalent yet |
| `/calendar/events/{id}/occurrences/{date}.json` | PATCH | SDK `CalendarEvents().UpdateOccurrence` | `hey event edit <id> --occurrence <id>_<date> --apply-to current\|future`, Calendar TUI `e` on one day of a repeating event | covered: `current` is `apply_to_future=0` and `future` is `apply_to_future=1`; the CLI sends the day's own schedule, content, reminders, circle and countdown back, and refuses a schedule change for `current` |
| `/calendar/events/{id}/occurrences/{date}.json` | DELETE | SDK `CalendarEvents().DeleteOccurrence` | Calendar TUI `x` on one day of a repeating event | covered; no CLI equivalent yet |
| `/calendar/events/{id}.json` | GET | — | — | not served: HEY has no JSON show for an event, which is why `hey event edit` finds one through the recordings listing |
| `/calendar/habits.json` | POST | SDK `Habits().Create` | `hey habit create`, Calendar TUI `a` | covered |
Expand Down
51 changes: 49 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,8 @@ hey event add "Sarah's birthday" --starts-on 2026-09-02 # no time, so all da
hey event add "Standup" --start-time 09:15 --repeat every_weekday --remind 10m
hey event edit 4821 --title "Design review (moved)"
hey event edit 4821 --starts-on 2026-09-04 --start-time 15:00
hey event edit 4821 --occurrence 4821_2026-09-15 --apply-to current --start-time 15:00 # that day alone
hey event edit 4821 --occurrence 4821_2026-09-15 --apply-to future --location "Studio, 3rd floor" --allow-plain-notes
hey event delete 4821
```

Expand All @@ -392,8 +394,10 @@ 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
day's own times and an `occurrence_id`. An occurrence HEY draws from the series carries the
series' id, which `hey event edit` and `hey event delete` take for the whole series; a day
HEY has written out on its own carries an id of its own, which those two act on for that
day alone, with the series in `parent_id`. 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.
Expand All @@ -411,6 +415,49 @@ one unless `--countdown` names it again. An event that cannot be read is refused
than written blind — pass the day it starts (`hey event edit 4821 2026-09-02`) or
`--calendar` to look somewhere narrower.

An id on its own changes the whole event, a repeating series included. One day of a
series is changed with `--occurrence`, which takes the `occurrence_id` that `hey event
day` and `hey event week` serve — `<series id>_<YYYY-MM-DD>`, byte for byte, naming the
series the positional id names — together with `--apply-to`, which is required with it
and is the choice HEY's own form puts to you: `current` changes that day alone, `future`
changes it and every day after it. `--apply-to` without `--occurrence` is a usage error,
as is any other value. The day is read on its own date rather than searched for, so
`[date]` can be left out or must name it. A change to `--repeat`, `--repeat-until` or
`--repeat-times` cannot apply to one day, so `current` refuses those flags; `future` takes
them. HEY splits the series on a `future` edit either way — the days from this one on
become a new series with a new id, the old series stops the day before, and the answer is
still the day you edited — so read the day or the week again for the new series id before
editing it further.

An occurrence edit keeps more than a whole-event edit does, and refuses what it cannot
keep. It sends back the day's own schedule and zones, notes, location, link, attached
email, reminders and circle, taking them from the day itself where HEY has already written
that day out on its own. A day like that lists in `day` and `week` with an id of its own,
which `hey event edit <id>` and `hey event delete <id>` act on for that day alone, and
with the series in `parent_id`, which is what `--occurrence` takes beside its
`occurrence_id`. The countdown is read back from the recording HEY keeps for it — on the
day, or on the day the series began in one more single-day read — and sent again, so it
survives unless `--countdown 0` removes it; a countdown whose length cannot be read back
stops the edit and says so. One day of a series with a countdown cannot lose it alone:
HEY shows a day the series' countdown whenever it has none of its own, so `--countdown 0`
with `current` is refused there, and `future` or an edit of the series is where it comes
off. Notes are still served only as plain text and nothing can tell formatted notes from
plain ones, so an occurrence edit that would send notes back as text is refused unless
`--allow-plain-notes` accepts the loss or `--notes` replaces them; an event with no notes
needs neither. A whole-event edit accepts `--allow-plain-notes` too, and it changes nothing
there. A `future` edit records the new series from the series' own guest list and sends
the invitations, so a day whose guest list had come to differ from the series' is refused
until `--invite` names the new series' list. The day is read over every calendar, so with
`--occurrence` the `--calendar` flag is only the calendar the day is moved to, and a day
already moved to another calendar stays there through a `future` edit. HEY answers the
write with not-found both for a date that is not a day of the series and for a series you
cannot edit.

One thing no edit can keep, whole event or one day: an attached email you cannot read is
left out of what HEY serves, indistinguishable from none, and HEY clears the attachment
whether the write sends an empty entry id or no entry id at all. Editing such an event
detaches the email; only HEY can change that.

### Todos

```bash
Expand Down
96 changes: 77 additions & 19 deletions internal/cmd/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func newEventsCommand() *eventsCommand {
Use: "event",
Short: "Read and manage calendar events",
Annotations: map[string]string{
"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.",
"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. edit <series id> changes a whole series; one day of it is edit <series id> --occurrence <occurrence_id from day/week> --apply-to current|future, which keeps the countdown and refuses to flatten notes unless --allow-plain-notes or --notes is given. After --apply-to future HEY splits the series, so read the day again for the new series id.",
},
}

Expand Down Expand Up @@ -164,7 +164,7 @@ func (c *eventsAddCommand) run(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
repeat, err := c.fields.parseRepeat()
repeat, err := c.fields.parseRepeat(cmd)
if err != nil {
return err
}
Expand Down Expand Up @@ -213,6 +213,13 @@ func (c *eventsAddCommand) run(cmd *cobra.Command, args []string) error {
type eventsEditCommand struct {
cmd *cobra.Command
fields eventFields

// occurrence and applyTo turn the edit into one of a repeating event's days: the
// occurrence_id a day or a week listing serves, and how much of the series the change
// reaches. allowPlainNotes accepts what that write cannot keep; see editOccurrence.
occurrence string
applyTo string
allowPlainNotes bool
}

func newEventsEditCommand() *eventsEditCommand {
Expand All @@ -229,16 +236,46 @@ countdown is not served at all, so an edit removes one unless --countdown names

The event is found by reading the calendars it might be on, which is one request each and
covers the pages HEY answers with. Give the day it starts as [date] to look on that day
alone, or --calendar to look on one calendar.`,
alone, or --calendar to look on one calendar.

An id alone changes the whole event, a repeating series included. One day of a series is
changed with --occurrence, which takes the occurrence_id 'hey event day' and 'hey event
week' serve (<series id>_<YYYY-MM-DD>, and the series must be the id given), and
--apply-to, which is required with it: 'current' changes that day alone and 'future'
changes it and every day after it, the two choices HEY's own form offers. The day is read
on its own date, so [date] can be left out or must name it. A change to --repeat,
--repeat-until or --repeat-times cannot apply to one day, so 'current' refuses those
flags; 'future' takes them, and HEY splits the series there either way, so the days from
this one on get a new series id.

An occurrence edit keeps more than a whole-event edit does, and refuses what it cannot
keep. The countdown is read back and sent again, so it survives unless --countdown 0
removes it — and one day of a series with a countdown cannot lose it alone, since HEY
shows a day the series' countdown whenever it has none of its own, so that is refused.
Notes are still only served as plain text, so an occurrence edit that would send
formatted notes back as text refuses unless --allow-plain-notes accepts that or --notes
replaces them. A 'future' edit records the new series from the series' own guest list,
so a day that had come to have guests of its own is refused until --invite names the new
series' list. The day is read over every calendar, so here --calendar is only where the
day is moved to; a day already moved elsewhere stays there. A day HEY has written out on
its own lists with an id of its own, which edits and deletes that day alone, and with the
series in parent_id. One thing no edit can keep: an attached email you cannot read is not
served, so it is detached by any edit, whole event or one day.`,
Example: ` hey event edit 4821 --title "Design review (moved)"
hey event edit 4821 --starts-on 2026-09-04 --start-time 15:00
hey event edit 4821 2026-09-02 --location "Studio, 3rd floor"
hey event edit 4821 --circle=false`,
hey event edit 4821 --circle=false
hey event edit 4821 --occurrence 4821_2026-09-15 --apply-to current --start-time 15:00 --json
hey event edit 4821 --occurrence 4821_2026-09-15 --apply-to future --location "Studio, 3rd floor" --allow-plain-notes`,
RunE: eventsEditCommand.run,
Args: cobra.RangeArgs(1, 2),
}

eventsEditCommand.fields.registerFlags(eventsEditCommand.cmd)
flags := eventsEditCommand.cmd.Flags()
flags.StringVar(&eventsEditCommand.occurrence, "occurrence", "", "One day of a repeating event, by the occurrence_id 'hey event day' serves (<series id>_<YYYY-MM-DD>)")
flags.StringVar(&eventsEditCommand.applyTo, "apply-to", "", "How much of the series an --occurrence edit reaches: current (that day alone) or future (that day and every one after it)")
flags.BoolVar(&eventsEditCommand.allowPlainNotes, "allow-plain-notes", false, "Let an --occurrence edit send notes it is not changing back as plain text, losing their formatting")

return eventsEditCommand
}
Expand All @@ -261,7 +298,15 @@ func (c *eventsEditCommand) run(cmd *cobra.Command, args []string) error {
on = args[1]
}

occurrence, err := c.parseOccurrence(cmd, id, on)
if err != nil {
return err
}
ctx := cmd.Context()
if occurrence != nil {
return c.editOccurrence(ctx, cmd, *occurrence)
}

event, err := c.findEvent(ctx, id, on)
if err != nil {
return err
Expand All @@ -271,7 +316,7 @@ func (c *eventsEditCommand) run(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
repeat, err := c.fields.parseRepeat()
repeat, err := c.fields.parseRepeat(cmd)
if err != nil {
return err
}
Expand Down Expand Up @@ -334,18 +379,7 @@ func (c *eventsEditCommand) run(cmd *cobra.Command, args []string) error {
// never contain a timed event, which is how editing an event by its own day used to
// answer not-found. Reading a day too many is harmless here: the event is matched by id.
func (c *eventsEditCommand) findEvent(ctx context.Context, id int64, on string) (generated.Recording, error) {
endsOn := on
if day, err := time.Parse("2006-01-02", on); err == nil {
endsOn = day.AddDate(0, 0, 1).Format("2006-01-02")
}
filter := recordingFilter{
calendar: c.fields.calendar,
startsOn: on,
endsOn: endsOn,
defaultWindow: func(now time.Time) (time.Time, time.Time) { return now.AddDate(-1, 0, 0), now.AddDate(1, 0, 0) },
defaultCalendars: allCalendarIDs,
}
window, err := filter.resolve(ctx)
window, err := c.searchWindow(ctx, on)
if err != nil {
return generated.Recording{}, err
}
Expand All @@ -364,6 +398,24 @@ func (c *eventsEditCommand) findEvent(ctx context.Context, id int64, on string)
fmt.Sprintf("hey event edit %d <YYYY-MM-DD> reads the day it starts on", id))
}

// searchWindow is where an edit looks for its event: the day given, read as [day, day+1),
// or a window wide enough to cover an event somebody is editing, over the calendar
// --calendar names or every one of them.
func (c *eventsEditCommand) searchWindow(ctx context.Context, on string) (recordingWindow, error) {
endsOn := on
if day, err := time.Parse(dateLayout, on); err == nil {
endsOn = day.AddDate(0, 0, 1).Format(dateLayout)
}
filter := recordingFilter{
calendar: c.fields.calendar,
startsOn: on,
endsOn: endsOn,
defaultWindow: func(now time.Time) (time.Time, time.Time) { return now.AddDate(-1, 0, 0), now.AddDate(1, 0, 0) },
defaultCalendars: allCalendarIDs,
}
return filter.resolve(ctx)
}

// delete

type eventsDeleteCommand struct {
Expand Down Expand Up @@ -650,14 +702,20 @@ func checkEventDates(startsOn, endsOn string) error {

// parseRepeat reads the recurrence flags into the three fields HEY takes. Nil is no change,
// which on a whole-event update leaves the recurrence as it was.
func (f *eventFields) parseRepeat() (*hey.RepeatParams, error) {
func (f *eventFields) parseRepeat(cmd *cobra.Command) (*hey.RepeatParams, error) {
timesGiven := cmd.Flags().Changed("repeat-times")
if f.repeat == "" {
if f.repeatUntil == "" && f.repeatTimes == 0 {
if f.repeatUntil == "" && !timesGiven {
return nil, nil
}
return nil, apierr.ErrUsageHint("repeat-until and repeat-times need --repeat",
"hey event add \"Standup\" --repeat every_weekday --repeat-times 20")
}
// A count of nothing is not "forever", which is what the zero value would have meant.
if timesGiven && f.repeatTimes < 1 {
return nil, apierr.ErrUsageHint(fmt.Sprintf("repeat-times %d is not a number of occurrences", f.repeatTimes),
"a count of at least 1, or --repeat-until for a last day")
}

frequencies := map[string]hey.RepeatFrequency{
"every_day": hey.RepeatEveryDay,
Expand Down
Loading
Loading