From 60a2d0b8304ffd533c63bf9e0fe9065e0cfacef7 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sun, 13 Sep 2026 15:40:09 -0400 Subject: [PATCH 1/4] Edit one day of a repeating event with --occurrence and --apply-to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hey event edit changed a whole series and nothing else; the day view served an occurrence_id nothing on the CLI could take. HEY's own form puts a choice to anyone editing one day of a series — update just this event, or this and all future events — and the SDK already spoke both through UpdateOccurrence. The Omarchy calendar plugin wants that choice in a machine-safe shape. --occurrence takes the occurrence_id exactly as day and week serve it, naming the series the positional id names; --apply-to is required with it and takes current or future, which are apply_to_future=0 and =1 on the wire. The day is read on its own date, as the window HEY answers with every series still recurring through it and with the day itself where an earlier edit already wrote it out, so no wider scan is needed and a day HEY has made its own keeps its own title, times and countdown. A schedule change cannot apply to one day, so current refuses the repeat flags. An occurrence edit keeps what a whole-event edit loses, or refuses. The countdown is a recording of its own under the event, so it is read back — from the day, or from the series' first day in one more bounded read — and sent again; only --countdown 0 removes it, and a label that cannot be read back stops the edit. Notes are served only as plain text and nothing can tell formatted ones from plain, so an edit that would send notes back as text refuses unless --allow-plain-notes accepts the loss or --notes replaces them. The circle is sent back too, because a future edit records a new series that HEY circles only when told. A whole-event edit is unchanged on the wire. HEY's 404 on the occurrence route arrives from the SDK as a bare form error, so the edit names it as the not-found it is, with both things it can mean. --- .surface | 3 + API-COVERAGE.md | 4 +- docs/cli.md | 28 ++ internal/cmd/events.go | 74 ++- internal/cmd/events_occurrence.go | 407 +++++++++++++++ internal/cmd/events_occurrence_test.go | 671 +++++++++++++++++++++++++ internal/cmd/recording_filter.go | 15 +- skills/hey/SKILL.md | 24 + 8 files changed, 1206 insertions(+), 20 deletions(-) create mode 100644 internal/cmd/events_occurrence.go create mode 100644 internal/cmd/events_occurrence_test.go diff --git a/.surface b/.surface index 4ebe280a..ab9b9a8f 100644 --- a/.surface +++ b/.surface @@ -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 @@ -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 diff --git a/API-COVERAGE.md b/API-COVERAGE.md index ee2e7377..2b6e07d1 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -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 ` (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 ` (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 `, `hey attachment list ` | covered, but see the paging note below | | `/topics/{id}/publication` | POST | SDK `Publications().Create` | `hey share ` | covered | | `/topics/{id}/publication.json` | GET | SDK `Publications().Create` readback | `hey share ` | covered | @@ -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 `, 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 `, 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 --occurrence _ --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 | diff --git a/docs/cli.md b/docs/cli.md index 3e66e59a..de9726fc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 ``` @@ -411,6 +413,32 @@ 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 — `_`, 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. The countdown is read back from the recording HEY keeps for it +and sent again, so it survives unless `--countdown 0` removes it. 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. 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. + ### Todos ```bash diff --git a/internal/cmd/events.go b/internal/cmd/events.go index e4ad9d8b..071807cf 100644 --- a/internal/cmd/events.go +++ b/internal/cmd/events.go @@ -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 changes a whole series; one day of it is edit --occurrence --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.", }, } @@ -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 { @@ -229,16 +236,38 @@ 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 (_, 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. 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.`, 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 (_)") + 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 } @@ -261,7 +290,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 @@ -334,18 +371,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 } @@ -364,6 +390,24 @@ func (c *eventsEditCommand) findEvent(ctx context.Context, id int64, on string) fmt.Sprintf("hey event edit %d 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 { diff --git a/internal/cmd/events_occurrence.go b/internal/cmd/events_occurrence.go new file mode 100644 index 00000000..a3708945 --- /dev/null +++ b/internal/cmd/events_occurrence.go @@ -0,0 +1,407 @@ +package cmd + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strconv" + "time" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + "github.com/basecamp/hey-sdk/go/pkg/hey" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/terminal" +) + +// recordingTypeCountdown is how HEY names an event's countdown among a calendar's +// recordings. A countdown is not a field on the event but a recording of its own kind +// under it, spanning from the countdown's start to the moment the event begins. +const recordingTypeCountdown = "Calendar::Countdown" + +// occurrenceEdit is one day of a repeating event and how much of the series a change to it +// reaches, as --occurrence and --apply-to name them. +type occurrenceEdit struct { + occurrence hey.EventOccurrence + scope hey.OccurrenceScope +} + +// parseOccurrence reads --occurrence and --apply-to, and answers nil for an edit of the +// whole event. Everything here is refused before a request is made: the occurrence_id has +// to be the one a day or a week listing served, byte for byte, naming the series the +// positional id names; --apply-to has to say current or future; and a change to the +// schedule cannot apply to one day, which is HEY's rule as much as this command's. +func (c *eventsEditCommand) parseOccurrence(cmd *cobra.Command, id int64, on string) (*occurrenceEdit, error) { + flags := cmd.Flags() + if c.occurrence == "" { + if flags.Changed("apply-to") { + return nil, apierr.ErrUsageHint("--apply-to needs --occurrence", + "hey event edit 4821 --occurrence 4821_2026-09-15 --apply-to current") + } + return nil, nil + } + + occurrence, err := hey.ParseOccurrenceID(c.occurrence) + if err != nil || occurrence.String() != c.occurrence { + return nil, apierr.ErrUsageHint(fmt.Sprintf("invalid occurrence: %s", c.occurrence), + "an occurrence_id as hey event day serves it, _, for example 4821_2026-09-15") + } + if occurrence.EventID != id { + return nil, apierr.ErrUsageHint( + fmt.Sprintf("occurrence %s belongs to series %d, not %d", c.occurrence, occurrence.EventID, id), + fmt.Sprintf("hey event edit %d --occurrence %s", occurrence.EventID, c.occurrence)) + } + if on != "" { + day, dateErr := parseDateArg("date", on) + if dateErr != nil { + return nil, dateErr + } + if day.Format(dateLayout) != occurrence.DateParam() { + return nil, apierr.ErrUsageHint(fmt.Sprintf("date %s is not the day of occurrence %s", on, c.occurrence), + "an occurrence is read on its own day, so leave the date out or name that day") + } + } + + scope, err := parseApplyTo(c.applyTo, flags.Changed("apply-to")) + if err != nil { + return nil, err + } + if scope == hey.OccurrenceScopeThisEvent { + for _, flag := range []string{"repeat", "repeat-until", "repeat-times"} { + if flags.Changed(flag) { + return nil, apierr.ErrUsageHint(fmt.Sprintf("--%s cannot apply to one day of a series", flag), + "a change to the schedule reaches this day and every one after it with --apply-to future, or the whole series when the id is edited alone") + } + } + } + + return &occurrenceEdit{occurrence: occurrence, scope: scope}, nil +} + +// parseApplyTo reads the scope. There is no default: a caller who does not say how much of +// the series to change has not said what they want, and the narrower answer is not safer +// when the wider one was meant. +func parseApplyTo(value string, given bool) (hey.OccurrenceScope, error) { + if !given { + return "", apierr.ErrUsageHint("--apply-to is required with --occurrence", + "--apply-to current changes that day alone; --apply-to future changes it and every day after it") + } + switch value { + case "current": + return hey.OccurrenceScopeThisEvent, nil + case "future": + return hey.OccurrenceScopeThisAndFollowing, nil + } + return "", apierr.ErrUsageHint(fmt.Sprintf("invalid apply-to: %s", value), "one of current or future") +} + +// editOccurrence changes one day of a repeating event, or that day and every one after it. +// +// The day is read on its own date, as the window [day, day+1) over the calendars the event +// could be on: HEY answers that window with every series still recurring through it, so the +// series is found without a scan for where it began, and with the day itself if an earlier +// edit has already written it out as a recording of its own — whose title, notes and times +// are then its own rather than the series', and are what an edit has to keep. +// +// The write is a replacement like every event write, so what the flags do not name is read +// back and sent again: the schedule of that day, its zones, notes, location, link, attached +// email, reminders, circle and countdown. Two things the whole-event edit loses are handled +// here rather than lost. The countdown is read from the recording HEY keeps for it and sent +// again, so only --countdown 0 removes it. Notes are served only as plain text, so an edit +// that would send formatted notes back as text is refused unless --allow-plain-notes says +// that is acceptable or --notes replaces them. +func (c *eventsEditCommand) editOccurrence(ctx context.Context, cmd *cobra.Command, edit occurrenceEdit) error { + // The flags that need no read are refused first, so a bad one costs no request. + repeat, err := c.fields.parseRepeat() + if err != nil { + return err + } + if _, err = c.fields.parseCountdown(); err != nil { + return err + } + + window, err := c.searchWindow(ctx, edit.occurrence.DateParam()) + if err != nil { + return err + } + rows, err := window.readTypes(ctx, recordingTypeEvent, recordingTypeCountdown) + if err != nil { + return err + } + day, err := locateOccurrence(rows, edit.occurrence) + if err != nil { + return err + } + event := day.event() + + if event.Description != "" && !cmd.Flags().Changed("notes") && !c.allowPlainNotes { + return errPlainNotes(edit.occurrence) + } + + schedule, err := c.fields.scheduleFrom(cmd, event) + if err != nil { + return err + } + reminders, err := c.fields.remindersFrom(cmd, event) + if err != nil { + return err + } + countdown, err := c.occurrenceCountdown(ctx, cmd, window, day) + if err != nil { + return err + } + + changes := hey.UpdateCalendarEventParams{ + StartsAt: &schedule.startsAt, + EndsAt: &schedule.endsAt, + AllDay: &schedule.allDay, + StartTime: &schedule.startTime, + EndTime: &schedule.endTime, + StartTimeZone: &schedule.zone, + EndTimeZone: &schedule.endZone, + Reminders: reminders, + Content: hey.EventContentParams{ + Notes: stringOr(cmd, "notes", c.fields.notes, event.Description), + Location: stringOr(cmd, "location", c.fields.location, event.Location), + Link: stringOr(cmd, "link", c.fields.link, event.Url), + EntryID: event.AttachedEntry.Id, + }, + Attendees: c.fields.invitesOrNil(cmd), + Countdown: countdown, + Repeat: repeat, + } + if title := stringOr(cmd, "title", c.fields.title, event.Title); title != "" { + changes.Title = &title + } + if cmd.Flags().Changed("calendar") { + changes.CalendarID = &c.fields.calendar + } + // The circle is sent back whether or not it changes. A future edit records a new series + // for the days from this one on, and HEY circles that one only when told to; the + // whole-event edit can leave the flag out because there the event stays the same record. + circled := event.Highlighted + if cmd.Flags().Changed("circle") { + circled = c.fields.circle + } + changes.Highlighted = &circled + + result, err := sdk.CalendarEvents().UpdateOccurrence(ctx, edit.occurrence, edit.scope, + hey.UpdateCalendarEventOccurrenceParams{UpdateCalendarEventParams: changes}) + if err != nil { + return occurrenceWriteError(err, edit.occurrence) + } + + summary := "Occurrence updated" + if edit.scope == hey.OccurrenceScopeThisAndFollowing { + summary = "Occurrence and the following updated" + } + return writeMutationLine(cmd, + fmt.Sprintf("%s.%s", summary, extractMutationInfoFromResult(result)), + summary, + result) +} + +// occurrenceWriteError says what HEY's refusal of an occurrence write means. The occurrence +// route answers not-found for a date that is not a day of the series and for a series the +// caller may not edit alike, and the SDK reports a form route's status as a bare API +// error, so the status is read here into the not-found it is, with both meanings named. +func occurrenceWriteError(err error, occurrence hey.EventOccurrence) error { + if hey.AsError(err).HTTPStatus == http.StatusNotFound { + return apierr.ErrNotFoundHint("occurrence", occurrence.String(), + fmt.Sprintf("HEY refuses a date that is not a day of the series and a series you cannot edit alike; hey event day %s lists that day's occurrences", occurrence.DateParam())) + } + return apierr.FromSDK(err) +} + +// occurrenceDay is what the day of an occurrence holds for it: the series it belongs to, +// the day itself where HEY has already written it out as a recording of its own, and the +// countdowns ending that day. +type occurrenceDay struct { + occurrence hey.EventOccurrence + series generated.Recording + realized *generated.Recording + countdowns []generated.Recording +} + +// locateOccurrence picks the occurrence's rows out of the day's. The series is matched by +// id and has to be one: an id that names a day of some other series, or an event that does +// not repeat, is refused rather than written through the occurrence route to be answered +// not-found. The written-out day is matched by its occurrence_id alone, which names the +// series and the date together. +func locateOccurrence(rows []generated.Recording, occurrence hey.EventOccurrence) (occurrenceDay, error) { + day := occurrenceDay{occurrence: occurrence} + found := false + for i := range rows { + row := rows[i] + switch { + case row.Type == recordingTypeCountdown: + day.countdowns = append(day.countdowns, row) + case row.Id == occurrence.EventID: + day.series = row + found = true + case row.OccurrenceId == occurrence.String(): + day.realized = &row + } + } + + id := strconv.FormatInt(occurrence.EventID, 10) + switch { + case !found: + return occurrenceDay{}, apierr.ErrNotFoundHint("occurrence", occurrence.String(), + fmt.Sprintf("hey event day %s lists that day's occurrences with their ids", occurrence.DateParam())) + case day.series.OccurrenceId != "": + return occurrenceDay{}, apierr.ErrUsageHint( + fmt.Sprintf("event %s is one day of series %d, not a series", id, day.series.ParentId), + fmt.Sprintf("hey event edit %d --occurrence %s", day.series.ParentId, day.series.OccurrenceId)) + case !day.series.Recurring: + return occurrenceDay{}, apierr.ErrUsageHint( + fmt.Sprintf("event %s does not repeat, so it has no occurrences", id), + fmt.Sprintf("hey event edit %s changes it", id)) + } + return day, nil +} + +// event is the day as an event: the recording HEY wrote out for it, or the series with the +// day's own times in place of the series' first. +func (d occurrenceDay) event() generated.Recording { + if d.realized != nil { + return *d.realized + } + return virtualOccurrence(d.series, d.occurrence.Date) +} + +// virtualOccurrence is one day of a series the way HEY builds it: the series' own fields, +// with the day's start and end in place of the first day's. It is what the write has to +// send, since a date the series began on would move the day there. +func virtualOccurrence(series generated.Recording, day time.Time) generated.Recording { + occurrence := series + occurrence.Id = 0 + occurrence.ParentId = series.Id + occurrence.OccurrenceId = hey.EventOccurrence{EventID: series.Id, Date: day}.String() + occurrence.StartsAt, occurrence.EndsAt = occurrenceInstants(series, day) + return occurrence +} + +// occurrenceInstants is when a day of the series starts and ends. HEY names the day by the +// UTC date of its start and gives it the series' wall-clock time in the series' own zone, +// so a series that starts late in the evening west of Greenwich, or early in the morning +// east of it, has its wall-clock day on the day either side of the one named. The end +// follows the start by the series' own length. +func occurrenceInstants(series generated.Recording, day time.Time) (time.Time, time.Time) { + duration := series.EndsAt.Sub(series.StartsAt) + if series.AllDay { + start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, time.UTC) + return start, start.Add(duration) + } + + loc := time.UTC + if series.StartsAtTimeZone != "" { + if zone, err := time.LoadLocation(series.StartsAtTimeZone); err == nil { + loc = zone + } + } + wall := series.StartsAt.In(loc) + named := day.Format(dateLayout) + start := time.Date(day.Year(), day.Month(), day.Day(), wall.Hour(), wall.Minute(), wall.Second(), 0, loc) + for _, delta := range []int{0, -1, 1} { + candidate := time.Date(day.Year(), day.Month(), day.Day()+delta, wall.Hour(), wall.Minute(), wall.Second(), 0, loc) + if candidate.UTC().Format(dateLayout) == named { + start = candidate + break + } + } + return start, start.Add(duration) +} + +// occurrenceCountdown is the countdown the write sends: the one --countdown names, or the +// one the event already has, read back so that a write that says nothing about it does +// not remove it. --countdown 0 is how it is removed on purpose. +// +// A countdown is a recording of its own under the event, ending when the event starts. A +// day written out on its own may carry one, ending on this day; the series' ends on the +// day the series began, which is this day only for the first occurrence and otherwise one +// more read of that one day, on the series' own calendar. +func (c *eventsEditCommand) occurrenceCountdown(ctx context.Context, cmd *cobra.Command, window recordingWindow, day occurrenceDay) (hey.CountdownParams, error) { + if cmd.Flags().Changed("countdown") { + return c.fields.parseCountdown() + } + if day.realized != nil { + if countdown, ok := countdownOf(day.countdowns, day.realized.Id); ok { + return countdownFromLabel(countdown.Label) + } + } + if countdown, ok := countdownOf(day.countdowns, day.series.Id); ok { + return countdownFromLabel(countdown.Label) + } + + if day.series.StartsAt.IsZero() { + return hey.CountdownParams{}, nil + } + began := day.series.StartsAt.UTC() + startsOn := began.Format(dateLayout) + if startsOn == day.occurrence.DateParam() { + return hey.CountdownParams{}, nil + } + calendars := window.calendars + if day.series.Calendar.Id != 0 { + calendars = []int64{day.series.Calendar.Id} + } + firstDay := recordingWindow{calendars: calendars, startsOn: startsOn, endsOn: began.AddDate(0, 0, 1).Format(dateLayout)} + countdowns, err := firstDay.readTypes(ctx, recordingTypeCountdown) + if err != nil { + return hey.CountdownParams{}, err + } + if countdown, ok := countdownOf(countdowns, day.series.Id); ok { + return countdownFromLabel(countdown.Label) + } + return hey.CountdownParams{}, nil +} + +// countdownOf is the countdown recording under the event with that id, if the rows hold one. +func countdownOf(rows []generated.Recording, eventID int64) (generated.Recording, bool) { + for _, row := range rows { + if row.Type == recordingTypeCountdown && row.ParentId == eventID { + return row, true + } + } + return generated.Recording{}, false +} + +// countdownLabel is the one thing HEY serves about a countdown's length: its label, "3 weeks +// before", from which the value and the unit its own form would submit are read back. +var countdownLabel = regexp.MustCompile(`^(\d+) (day|week|month)s? before$`) + +// countdownFromLabel reads the countdown back out of its label. A label in a shape this +// cannot read is refused rather than guessed at or dropped: the caller can still name the +// countdown with --countdown, or remove it with --countdown 0. +func countdownFromLabel(label string) (hey.CountdownParams, error) { + match := countdownLabel.FindStringSubmatch(label) + if match == nil { + return hey.CountdownParams{}, &apierr.Error{ + Code: apierr.CodeAPI, + Message: fmt.Sprintf("the event's countdown could not be read back: %q", terminal.SanitizeLine(label)), + Hint: "pass --countdown with --countdown-unit to keep it, or --countdown 0 to remove it", + } + } + value, _ := strconv.Atoi(match[1]) + units := map[string]hey.CountdownUnit{ + "day": hey.CountdownUnitDays, + "week": hey.CountdownUnitWeeks, + "month": hey.CountdownUnitMonths, + } + return hey.CountdownParams{Value: value, Unit: units[match[2]]}, nil +} + +// errPlainNotes is how an occurrence edit refuses to flatten notes it was not asked to +// change. HEY serves them as plain text and takes back whatever it is sent, so nothing here +// can tell formatted notes from plain ones; the caller has to say the loss is acceptable. +func errPlainNotes(occurrence hey.EventOccurrence) error { + return &apierr.Error{ + Code: apierr.CodeUsage, + Message: fmt.Sprintf("occurrence %s has notes, which HEY serves only as plain text, so sending them back would lose their formatting", occurrence), + Hint: "pass --allow-plain-notes to send them back as text, or --notes to replace them", + } +} diff --git a/internal/cmd/events_occurrence_test.go b/internal/cmd/events_occurrence_test.go new file mode 100644 index 00000000..80d867c1 --- /dev/null +++ b/internal/cmd/events_occurrence_test.go @@ -0,0 +1,671 @@ +package cmd + +import ( + "bytes" + "errors" + "io" + "net/http" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + "github.com/basecamp/hey-sdk/go/pkg/hey" + + "github.com/basecamp/hey-cli/internal/apierr" +) + +// The series as a calendar's recordings listing serves it: one row, on the day it began, +// carrying everything a write has to send back. +const occurrenceSeriesJSON = `{"id":4821,"type":"Calendar::Event","title":"Design review","recurring":true,` + + `"starts_at":"2026-09-01T12:00:00Z","ends_at":"2026-09-01T13:00:00Z",` + + `"starts_at_time_zone":"Europe/Zagreb","ends_at_time_zone":"Europe/Zagreb",` + + `"description":"Bring the roadmap","location":"Studio, 3rd floor","url":"https://meet.example.com/design",` + + `"attached_entry":{"id":551},"reminders":[{"duration":600}],"highlighted":true,` + + `"recurrence_schedule":{"kind":"every_week","preset":true},"calendar":{"id":9,"name":"Work"}}` + +// The countdown HEY keeps under the series: a recording of its own, ending where the series +// begins, with the only description of its length in its label. +const occurrenceCountdownJSON = `{"id":77,"type":"Calendar::Countdown","parent_id":4821,"label":"3 weeks before",` + + `"starts_at":"2026-08-11T00:00:00Z","ends_at":"2026-09-01T12:00:00Z","calendar":{"id":9,"name":"Work"}}` + +// occurrenceServer answers what an occurrence edit reads and writes. The day read is the +// occurrence's own day and the first-day read is the series' first day, where its +// countdown sits; an empty firstDay says that read must not happen. +func occurrenceServer(t *testing.T, date, day, firstDay string, onPatch func(t *testing.T, form url.Values)) (http.Handler, *atomic.Int32) { + t.Helper() + next := func(from string) string { + parsed, err := time.Parse(dateLayout, from) + if err != nil { + t.Fatalf("date %q: %v", from, err) + } + return parsed.AddDate(0, 0, 1).Format(dateLayout) + } + var writes atomic.Int32 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + query := r.URL.Query() + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Work","owned":true}}]}`) + case r.Method == http.MethodGet && r.URL.Path == "/calendars/9/recordings.json" && query.Get("starts_on") == date: + if got := query.Get("ends_on"); got != next(date) { + t.Errorf("ends_on = %q, want the day after %s", got, date) + } + _, _ = io.WriteString(w, day) + case r.Method == http.MethodGet && r.URL.Path == "/calendars/9/recordings.json" && query.Get("starts_on") == "2026-09-01": + if firstDay == "" { + t.Errorf("read the series' first day, want no such read") + } + if got := query.Get("ends_on"); got != "2026-09-02" { + t.Errorf("ends_on = %q, want the day after the series began", got) + } + _, _ = io.WriteString(w, firstDay) + case r.Method == http.MethodPatch && r.URL.Path == "/calendar/events/4821/occurrences/"+date+".json": + writes.Add(1) + onPatch(t, eventForm(t, r)) + _, _ = io.WriteString(w, `{"id":9001,"parent_id":4821,"occurrence_id":"4821_`+date+`","title":"Design review (moved)"}`) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL) + http.NotFound(w, r) + } + }), &writes +} + +// The one that matters: one day of the series is written through the occurrence route with +// apply_to_future off, on the day's own date at the series' own clock time, and everything +// the flags did not name — notes, location, link, attached email, reminders, zones, the +// circle and the countdown — is sent back. +func TestEventsEditOccurrenceCurrentChangesThatDayAlone(t *testing.T) { + handler, writes := occurrenceServer(t, "2026-09-15", + `{"Calendar::Event":[`+occurrenceSeriesJSON+`]}`, + `{"Calendar::Countdown":[`+occurrenceCountdownJSON+`]}`, + func(t *testing.T, form url.Values) { + want := map[string]string{ + "apply_to_future": "0", + "repeat_frequency": "custom", + "calendar_event[summary]": "Design review (moved)", + "calendar_event[starts_at]": "2026-09-15", + "calendar_event[ends_at]": "2026-09-15", + "calendar_event[all_day]": "0", + "calendar_event[starts_at_time]": "14:00:00", + "calendar_event[ends_at_time]": "15:00:00", + "calendar_event[starts_at_time_zone_name]": "Europe/Zagreb", + "calendar_event[ends_at_time_zone_name]": "Europe/Zagreb", + "calendar_event[description]": "Bring the roadmap", + "calendar_event[location]": "Studio, 3rd floor", + "calendar_event[url]": "https://meet.example.com/design", + "calendar_event[entry_id]": "551", + "calendar_event[highlighted]": "1", + "countdown_interval_duration_value": "3", + "countdown_interval_duration_unit": "604800", + } + for field, value := range want { + if got := form.Get(field); got != value { + t.Errorf("%s = %q, want %q", field, got, value) + } + } + if got := form["timed_reminder_durations[]"]; len(got) != 1 || got[0] != "600" { + t.Errorf("reminders = %v, want the ten-minute reminder sent back", got) + } + if got := form["calendar_event[attendance_email_addresses][]"]; got != nil { + t.Errorf("attendees = %v, want none submitted", got) + } + if form.Has("calendar_event[calendar_id]") { + t.Errorf("calendar_id = %q, want the day left on its calendar", form.Get("calendar_event[calendar_id]")) + } + if form.Has("calendar_recurrence_schedule[recurs_until_type]") { + t.Errorf("recurs_until_type was sent, want the schedule left alone") + } + }) + + response, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", + "--title", "Design review (moved)", "--allow-plain-notes") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } + if writes.Load() != 1 { + t.Errorf("writes = %d, want one", writes.Load()) + } + if response.Summary != "Occurrence updated" { + t.Errorf("summary = %q", response.Summary) + } + data, ok := response.Data.(map[string]any) + if !ok || data["occurrence_id"] != "4821_2026-09-15" { + t.Errorf("structured data = %#v, want the occurrence HEY answered", response.Data) + } +} + +// The wider scope is the same write with apply_to_future on. It is the one that may change +// the schedule, and a schedule it is not given is left as it is rather than ended. +func TestEventsEditOccurrenceFutureChangesTheDaysFromThisOneOn(t *testing.T) { + handler, _ := occurrenceServer(t, "2026-09-15", + `{"Calendar::Event":[`+occurrenceSeriesJSON+`]}`, + `{"Calendar::Countdown":[`+occurrenceCountdownJSON+`]}`, + func(t *testing.T, form url.Values) { + if got := form.Get("apply_to_future"); got != "1" { + t.Errorf("apply_to_future = %q, want 1", got) + } + if got := form.Get("repeat_frequency"); got != "every_other_week" { + t.Errorf("repeat_frequency = %q", got) + } + if got := form.Get("calendar_recurrence_schedule[recurs_until_type]"); got != "forever" { + t.Errorf("recurs_until_type = %q", got) + } + if got := form.Get("calendar_event[starts_at]"); got != "2026-09-15" { + t.Errorf("starts_at = %q, want the new series to begin on this day", got) + } + if got := form.Get("calendar_event[location]"); got != "Studio, 3rd floor" { + t.Errorf("location = %q, want it sent back", got) + } + if got := form.Get("countdown_interval_duration_value"); got != "3" { + t.Errorf("countdown value = %q, want the series' countdown on the new series", got) + } + }) + + response, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "future", + "--repeat", "every_other_week", "--allow-plain-notes") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } + if response.Summary != "Occurrence and the following updated" { + t.Errorf("summary = %q", response.Summary) + } +} + +// A day HEY has already written out as a recording of its own carries its own title, times +// and countdown, and those — not the series' — are what an edit of that day keeps. +func TestEventsEditOccurrencePrefersTheDayHEYWroteOut(t *testing.T) { + realized := `{"id":9001,"type":"Calendar::Event","parent_id":4821,"occurrence_id":"4821_2026-09-15",` + + `"title":"Design review (with the vendor)","starts_at":"2026-09-15T13:30:00Z","ends_at":"2026-09-15T14:30:00Z",` + + `"starts_at_time_zone":"Europe/Zagreb","ends_at_time_zone":"Europe/Zagreb","location":"Vendor's office",` + + `"reminders":[{"duration":1800}],"calendar":{"id":9,"name":"Work"}}` + countdown := `{"id":78,"type":"Calendar::Countdown","parent_id":9001,"label":"2 days before","calendar":{"id":9,"name":"Work"}}` + handler, _ := occurrenceServer(t, "2026-09-15", + `{"Calendar::Event":[`+occurrenceSeriesJSON+`,`+realized+`],"Calendar::Countdown":[`+countdown+`]}`, + "", + func(t *testing.T, form url.Values) { + if got := form.Get("calendar_event[summary]"); got != "Design review (with the vendor)" { + t.Errorf("summary = %q, want the day's own title", got) + } + if got := form.Get("calendar_event[starts_at_time]"); got != "15:30:00" { + t.Errorf("starts_at_time = %q, want the day's own clock time", got) + } + if got := form.Get("calendar_event[location]"); got != "Vendor's office" { + t.Errorf("location = %q, want the day's own location", got) + } + if got := form.Get("calendar_event[description]"); got != "" { + t.Errorf("description = %q, want the day's own (none)", got) + } + if got := form["timed_reminder_durations[]"]; len(got) != 1 || got[0] != "1800" { + t.Errorf("reminders = %v, want the day's own", got) + } + if got := form.Get("countdown_interval_duration_value"); got != "2" { + t.Errorf("countdown value = %q, want the day's own", got) + } + if got := form.Get("countdown_interval_duration_unit"); got != "86400" { + t.Errorf("countdown unit = %q, want days", got) + } + if got := form.Get("calendar_event[highlighted]"); got != "0" { + t.Errorf("highlighted = %q, want the day's own", got) + } + }) + + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", "--link", "https://meet.example.com/vendor") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } +} + +// The series' first day is the one day its countdown is already on, so editing that +// occurrence reads nothing more. +func TestEventsEditOccurrenceOnTheFirstDayReadsOnce(t *testing.T) { + handler, _ := occurrenceServer(t, "2026-09-01", + `{"Calendar::Event":[`+occurrenceSeriesJSON+`],"Calendar::Countdown":[`+occurrenceCountdownJSON+`]}`, + "", + func(t *testing.T, form url.Values) { + if got := form.Get("countdown_interval_duration_value"); got != "3" { + t.Errorf("countdown value = %q, want the countdown found on the day", got) + } + if got := form.Get("calendar_event[starts_at]"); got != "2026-09-01" { + t.Errorf("starts_at = %q", got) + } + }) + + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "2026-09-01", "--occurrence", "4821_2026-09-01", "--apply-to", "current", + "--title", "Design review (moved)", "--allow-plain-notes") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } +} + +// Every flag combination that cannot mean anything is refused before a request is made. +func TestEventsEditOccurrenceRefusesWhatItCannotMean(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "apply-to without occurrence", args: []string{"--apply-to", "current"}, want: "--apply-to needs --occurrence"}, + {name: "occurrence without apply-to", args: []string{"--occurrence", "4821_2026-09-15"}, want: "--apply-to is required with --occurrence"}, + {name: "unknown scope", args: []string{"--occurrence", "4821_2026-09-15", "--apply-to", "all"}, want: "invalid apply-to: all"}, + {name: "no underscore", args: []string{"--occurrence", "4821-2026-09-15", "--apply-to", "current"}, want: "invalid occurrence: 4821-2026-09-15"}, + {name: "date without dashes", args: []string{"--occurrence", "4821_20260915", "--apply-to", "current"}, want: "invalid occurrence: 4821_20260915"}, + {name: "padded series", args: []string{"--occurrence", "04821_2026-09-15", "--apply-to", "current"}, want: "invalid occurrence: 04821_2026-09-15"}, + {name: "trailing text", args: []string{"--occurrence", "4821_2026-09-15x", "--apply-to", "current"}, want: "invalid occurrence: 4821_2026-09-15x"}, + {name: "no date", args: []string{"--occurrence", "4821_", "--apply-to", "current"}, want: "invalid occurrence: 4821_"}, + {name: "other series", args: []string{"--occurrence", "4822_2026-09-15", "--apply-to", "current"}, want: "occurrence 4822_2026-09-15 belongs to series 4822, not 4821"}, + {name: "date is not the day", args: []string{"2026-09-01", "--occurrence", "4821_2026-09-15", "--apply-to", "current"}, want: "date 2026-09-01 is not the day of occurrence 4821_2026-09-15"}, + {name: "repeat on one day", args: []string{"--occurrence", "4821_2026-09-15", "--apply-to", "current", "--repeat", "every_week"}, want: "--repeat cannot apply to one day of a series"}, + {name: "repeat-until on one day", args: []string{"--occurrence", "4821_2026-09-15", "--apply-to", "current", "--repeat-until", "2026-12-31"}, want: "--repeat-until cannot apply to one day of a series"}, + {name: "repeat-times on one day", args: []string{"--occurrence", "4821_2026-09-15", "--apply-to", "current", "--repeat-times", "4"}, want: "--repeat-times cannot apply to one day of a series"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var requests atomic.Int32 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + t.Errorf("unexpected request = %s %s", r.Method, r.URL) + http.NotFound(w, r) + }) + _, err := runJSONCommand(t, handler, append([]string{"event", "edit", "4821"}, tt.args...)...) + var cliErr *apierr.Error + if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage || cliErr.Message != tt.want { + t.Fatalf("error = %v, want usage %q", err, tt.want) + } + if requests.Load() != 0 { + t.Errorf("requests = %d, want none", requests.Load()) + } + }) + } +} + +// Notes are only served as plain text, so an edit that would send formatted notes back as +// text is refused until the caller says the loss is acceptable or replaces them. +func TestEventsEditOccurrenceRefusesToFlattenNotesUnasked(t *testing.T) { + t.Run("refused", func(t *testing.T) { + handler, writes := occurrenceServer(t, "2026-09-15", + `{"Calendar::Event":[`+occurrenceSeriesJSON+`]}`, + `{}`, + func(t *testing.T, form url.Values) {}) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", "--title", "Design review (moved)") + var cliErr *apierr.Error + if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage || !strings.Contains(cliErr.Message, "serves only as plain text") { + t.Fatalf("error = %v, want the plain-notes refusal", err) + } + if !strings.Contains(cliErr.Hint, "--allow-plain-notes") { + t.Errorf("hint = %q, want it to name the flag", cliErr.Hint) + } + if writes.Load() != 0 { + t.Errorf("writes = %d, want none", writes.Load()) + } + }) + + t.Run("replaced", func(t *testing.T) { + handler, writes := occurrenceServer(t, "2026-09-15", + `{"Calendar::Event":[`+occurrenceSeriesJSON+`]}`, + `{}`, + func(t *testing.T, form url.Values) { + if got := form.Get("calendar_event[description]"); got != "Bring the roadmap and the budget" { + t.Errorf("description = %q", got) + } + }) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", "--notes", "Bring the roadmap and the budget") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } + if writes.Load() != 1 { + t.Errorf("writes = %d, want one", writes.Load()) + } + }) + + t.Run("no notes to lose", func(t *testing.T) { + series := strings.Replace(occurrenceSeriesJSON, `"description":"Bring the roadmap",`, "", 1) + handler, writes := occurrenceServer(t, "2026-09-15", + `{"Calendar::Event":[`+series+`]}`, + `{}`, + func(t *testing.T, form url.Values) { + if got := form.Get("calendar_event[description]"); got != "" { + t.Errorf("description = %q, want none", got) + } + }) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", "--title", "Design review (moved)") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } + if writes.Load() != 1 { + t.Errorf("writes = %d, want one", writes.Load()) + } + }) +} + +// A day that cannot be read is not written: an occurrence whose series is not on its day, +// an id that is itself one day of a series, and an event that does not repeat are each +// refused with the reason, and nothing is sent. +func TestEventsEditOccurrenceRefusesADayItCannotRead(t *testing.T) { + realizedOther := `{"id":4821,"type":"Calendar::Event","parent_id":4000,"occurrence_id":"4000_2026-09-15","title":"Standup","starts_at":"2026-09-15T07:00:00Z","ends_at":"2026-09-15T07:15:00Z","calendar":{"id":9}}` + oneOff := strings.Replace(occurrenceSeriesJSON, `"recurring":true`, `"recurring":false`, 1) + tests := []struct { + name string + day string + code string + want string + }{ + {name: "not on its day", day: `{"Calendar::Event":[]}`, code: apierr.CodeNotFound, want: `occurrence "4821_2026-09-15" not found`}, + {name: "id is a day of another series", day: `{"Calendar::Event":[` + realizedOther + `]}`, code: apierr.CodeUsage, want: "event 4821 is one day of series 4000, not a series"}, + {name: "does not repeat", day: `{"Calendar::Event":[` + oneOff + `]}`, code: apierr.CodeUsage, want: "event 4821 does not repeat, so it has no occurrences"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler, writes := occurrenceServer(t, "2026-09-15", tt.day, "", func(t *testing.T, form url.Values) {}) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", "--title", "Design review (moved)", "--allow-plain-notes") + var cliErr *apierr.Error + if !errors.As(err, &cliErr) || cliErr.Code != tt.code || cliErr.Message != tt.want { + t.Fatalf("error = %v, want %s %q", err, tt.code, tt.want) + } + if writes.Load() != 0 { + t.Errorf("writes = %d, want none", writes.Load()) + } + }) + } +} + +// --countdown 0 is the one way an occurrence edit removes a countdown, and saying so means +// the countdown is not read back at all. +func TestEventsEditOccurrenceRemovesTheCountdownOnlyWhenTold(t *testing.T) { + handler, _ := occurrenceServer(t, "2026-09-15", + `{"Calendar::Event":[`+occurrenceSeriesJSON+`]}`, + "", + func(t *testing.T, form url.Values) { + for _, field := range []string{"countdown_interval_duration_value", "countdown_interval_duration_unit"} { + if form.Has(field) { + t.Errorf("%s = %q, want none", field, form.Get(field)) + } + } + }) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", "--countdown", "0", "--allow-plain-notes") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } +} + +// A countdown --countdown names replaces the one the series has, without reading it back. +func TestEventsEditOccurrenceTakesTheCountdownItIsGiven(t *testing.T) { + handler, _ := occurrenceServer(t, "2026-09-15", + `{"Calendar::Event":[`+occurrenceSeriesJSON+`]}`, + "", + func(t *testing.T, form url.Values) { + if got := form.Get("countdown_interval_duration_value"); got != "2" { + t.Errorf("countdown value = %q", got) + } + if got := form.Get("countdown_interval_duration_unit"); got != "2629746" { + t.Errorf("countdown unit = %q, want months", got) + } + }) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", + "--countdown", "2", "--countdown-unit", "months", "--allow-plain-notes") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } +} + +// A series with no countdown sends none, after looking on its first day for one. +func TestEventsEditOccurrenceSendsNoCountdownForASeriesWithout(t *testing.T) { + handler, writes := occurrenceServer(t, "2026-09-15", + `{"Calendar::Event":[`+occurrenceSeriesJSON+`]}`, + `{"Calendar::Event":[`+occurrenceSeriesJSON+`]}`, + func(t *testing.T, form url.Values) { + if form.Has("countdown_interval_duration_value") { + t.Errorf("countdown value = %q, want none", form.Get("countdown_interval_duration_value")) + } + }) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", "--title", "Design review (moved)", "--allow-plain-notes") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } + if writes.Load() != 1 { + t.Errorf("writes = %d, want one", writes.Load()) + } +} + +// A countdown whose label cannot be read back is not guessed at and not dropped: the edit +// stops and says how to name or remove it. +func TestEventsEditOccurrenceFailsClosedOnACountdownItCannotRead(t *testing.T) { + odd := strings.Replace(occurrenceCountdownJSON, "3 weeks before", "a while before", 1) + handler, writes := occurrenceServer(t, "2026-09-15", + `{"Calendar::Event":[`+occurrenceSeriesJSON+`]}`, + `{"Calendar::Countdown":[`+odd+`]}`, + func(t *testing.T, form url.Values) {}) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", "--title", "Design review (moved)", "--allow-plain-notes") + var cliErr *apierr.Error + if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeAPI || !strings.Contains(cliErr.Message, `"a while before"`) { + t.Fatalf("error = %v, want the countdown refusal", err) + } + if !strings.Contains(cliErr.Hint, "--countdown 0") { + t.Errorf("hint = %q, want it to say how to remove the countdown", cliErr.Hint) + } + if writes.Load() != 0 { + t.Errorf("writes = %d, want none", writes.Load()) + } +} + +// HEY's own refusal of the write — a date that is not a day of the series, or a series the +// caller cannot edit, both 404 on the occurrence route — reaches the caller as not-found. +func TestEventsEditOccurrenceReportsHEYsRefusal(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Work","owned":true}}]}`) + case r.Method == http.MethodGet && r.URL.Path == "/calendars/9/recordings.json": + _, _ = io.WriteString(w, `{"Calendar::Event":[`+occurrenceSeriesJSON+`]}`) + case r.Method == http.MethodPatch: + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{}`) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL) + http.NotFound(w, r) + } + }) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", "--title", "Design review (moved)", "--allow-plain-notes") + var cliErr *apierr.Error + if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeNotFound { + t.Fatalf("error = %v, want not-found", err) + } +} + +// Logged out, the occurrence edit is refused before anything is read, like every other +// data-access command. +func TestEventsEditOccurrenceRequiresAuth(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + t.Setenv("XDG_STATE_HOME", tmpDir) + t.Setenv("XDG_CACHE_HOME", tmpDir) + t.Setenv("HEY_TOKEN", "") + t.Setenv("HEY_NO_KEYRING", "1") + stubInteractive(t, false) + + root := newRootCmd() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"--json", "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", "--title", "Design review (moved)"}) + + err := root.Execute() + var cliErr *apierr.Error + if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeAuth { + t.Fatalf("error = %v, want auth", err) + } +} + +// An id on its own still changes the whole series through the event route, with nothing +// the occurrence flags add on the wire — --allow-plain-notes is accepted and changes +// nothing there, since a whole-event edit has always sent the notes back as text. +func TestEventsEditWholeSeriesIsUnchangedByTheOccurrenceFlags(t *testing.T) { + var writes atomic.Int32 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Work","owned":true}}]}`) + case r.Method == http.MethodGet && r.URL.Path == "/calendars/9/recordings.json": + _, _ = io.WriteString(w, `{"Calendar::Event":[`+occurrenceSeriesJSON+`],"Calendar::Countdown":[`+occurrenceCountdownJSON+`]}`) + case r.Method == http.MethodPatch && r.URL.Path == "/calendar/events/4821.json": + writes.Add(1) + form := eventForm(t, r) + if form.Has("apply_to_future") { + t.Errorf("apply_to_future = %q, want none on a whole-event edit", form.Get("apply_to_future")) + } + if form.Has("repeat_frequency") { + t.Errorf("repeat_frequency = %q, want the schedule left alone", form.Get("repeat_frequency")) + } + if form.Has("countdown_interval_duration_value") { + t.Errorf("countdown value = %q, want the whole-event edit unchanged", form.Get("countdown_interval_duration_value")) + } + if form.Has("calendar_event[highlighted]") { + t.Errorf("highlighted = %q, want the whole-event edit unchanged", form.Get("calendar_event[highlighted]")) + } + if got := form.Get("calendar_event[starts_at]"); got != "2026-09-01" { + t.Errorf("starts_at = %q, want the series' own first day", got) + } + if got := form.Get("calendar_event[description]"); got != "Bring the roadmap" { + t.Errorf("description = %q, want the notes sent back", got) + } + _, _ = io.WriteString(w, `{"id":4821,"title":"Design review (moved)"}`) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL) + http.NotFound(w, r) + } + }) + response, err := runJSONCommand(t, handler, "event", "edit", "4821", "--title", "Design review (moved)", "--allow-plain-notes") + if err != nil { + t.Fatalf("execute events edit: %v", err) + } + if writes.Load() != 1 { + t.Errorf("writes = %d, want one", writes.Load()) + } + if response.Summary != "Event updated" { + t.Errorf("summary = %q", response.Summary) + } +} + +// HEY names a day by the UTC date of its start and gives it the series' wall-clock time in +// the series' zone, so the wall-clock day can be the day either side of the one named. +func TestOccurrenceInstants(t *testing.T) { + timed := func(start, end, zone string) generated.Recording { + startsAt, err := time.Parse(time.RFC3339, start) + if err != nil { + t.Fatal(err) + } + endsAt, err := time.Parse(time.RFC3339, end) + if err != nil { + t.Fatal(err) + } + return generated.Recording{StartsAt: startsAt, EndsAt: endsAt, StartsAtTimeZone: zone, EndsAtTimeZone: zone} + } + day := func(date string) time.Time { + parsed, err := time.Parse(dateLayout, date) + if err != nil { + t.Fatal(err) + } + return parsed + } + tests := []struct { + name string + series generated.Recording + day string + start, end string + }{ + { + name: "afternoon in Zagreb", + series: timed("2026-09-01T12:00:00Z", "2026-09-01T13:00:00Z", "Europe/Zagreb"), + day: "2026-09-15", start: "2026-09-15T12:00:00Z", end: "2026-09-15T13:00:00Z", + }, + { + name: "late evening in New York, named by the next UTC day", + series: timed("2026-09-02T03:30:00Z", "2026-09-02T04:00:00Z", "America/New_York"), + day: "2026-09-16", start: "2026-09-16T03:30:00Z", end: "2026-09-16T04:00:00Z", + }, + { + name: "early morning in Tokyo, named by the previous UTC day", + series: timed("2026-08-31T16:00:00Z", "2026-08-31T17:00:00Z", "Asia/Tokyo"), + day: "2026-09-14", start: "2026-09-14T16:00:00Z", end: "2026-09-14T17:00:00Z", + }, + { + name: "across a DST change the wall-clock time holds", + series: timed("2026-10-01T12:00:00Z", "2026-10-01T13:00:00Z", "Europe/Zagreb"), + day: "2026-11-05", start: "2026-11-05T13:00:00Z", end: "2026-11-05T14:00:00Z", + }, + { + name: "no zone is UTC", + series: timed("2026-09-01T09:00:00Z", "2026-09-01T09:30:00Z", ""), + day: "2026-09-15", start: "2026-09-15T09:00:00Z", end: "2026-09-15T09:30:00Z", + }, + { + name: "two all-day days", + series: generated.Recording{AllDay: true, + StartsAt: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), EndsAt: time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC)}, + day: "2026-09-15", start: "2026-09-15T00:00:00Z", end: "2026-09-16T00:00:00Z", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + start, end := occurrenceInstants(tt.series, day(tt.day)) + if got := start.UTC().Format(time.RFC3339); got != tt.start { + t.Errorf("start = %s, want %s", got, tt.start) + } + if got := end.UTC().Format(time.RFC3339); got != tt.end { + t.Errorf("end = %s, want %s", got, tt.end) + } + }) + } +} + +func TestCountdownFromLabel(t *testing.T) { + tests := []struct { + label string + value int + unit hey.CountdownUnit + invalid bool + }{ + {label: "3 weeks before", value: 3, unit: hey.CountdownUnitWeeks}, + {label: "1 days before", value: 1, unit: hey.CountdownUnitDays}, + {label: "1 day before", value: 1, unit: hey.CountdownUnitDays}, + {label: "6 months before", value: 6, unit: hey.CountdownUnitMonths}, + {label: "Countdown", invalid: true}, + {label: "", invalid: true}, + {label: "3 fortnights before", invalid: true}, + {label: "three weeks before", invalid: true}, + } + for _, tt := range tests { + countdown, err := countdownFromLabel(tt.label) + if tt.invalid { + if err == nil { + t.Errorf("countdownFromLabel(%q) = %+v, want an error", tt.label, countdown) + } + continue + } + if err != nil { + t.Errorf("countdownFromLabel(%q): %v", tt.label, err) + continue + } + if countdown.Value != tt.value || countdown.Unit != tt.unit { + t.Errorf("countdownFromLabel(%q) = %+v, want %d %d", tt.label, countdown, tt.value, tt.unit) + } + } +} diff --git a/internal/cmd/recording_filter.go b/internal/cmd/recording_filter.go index 5164b2e5..10d22ff2 100644 --- a/internal/cmd/recording_filter.go +++ b/internal/cmd/recording_filter.go @@ -113,9 +113,16 @@ func (f *recordingFilter) resolve(ctx context.Context) (recordingWindow, error) // occurrences inside them. Series can start long before the requested window, which puts // them on a later page after newer one-off events even when they recur inside it. func (w recordingWindow) read(ctx context.Context, recType string) ([]generated.Recording, error) { + return w.readTypes(ctx, recType) +} + +// readTypes is read over several recording types at once, in one pass over the pages. An +// event's countdown is a recording of its own kind under the event, so an edit that means +// to keep one reads both kinds from the same window rather than paging it twice. +func (w recordingWindow) readTypes(ctx context.Context, recTypes ...string) ([]generated.Recording, error) { recordings := []generated.Recording{} for _, calendarID := range w.calendars { - calendarRecordings, err := w.readCalendar(ctx, calendarID, recType) + calendarRecordings, err := w.readCalendar(ctx, calendarID, recTypes) if err != nil { return nil, err } @@ -124,7 +131,7 @@ func (w recordingWindow) read(ctx context.Context, recType string) ([]generated. return recordings, nil } -func (w recordingWindow) readCalendar(ctx context.Context, calendarID int64, recType string) ([]generated.Recording, error) { +func (w recordingWindow) readCalendar(ctx context.Context, calendarID int64, recTypes []string) ([]generated.Recording, error) { params := &generated.GetCalendarRecordingsParams{StartsOn: &w.startsOn, EndsOn: &w.endsOn} recordings := []generated.Recording{} seenPages := map[string]bool{} @@ -138,7 +145,9 @@ func (w recordingWindow) readCalendar(ctx context.Context, calendarID int64, rec return recordings, nil } - recordings = append(recordings, filterRecordingsByType(page.Recordings, recType)...) + for _, recType := range recTypes { + recordings = append(recordings, filterRecordingsByType(page.Recordings, recType)...) + } if page.NextPage == "" || calendarRecordingsEmpty(page.Recordings) { return recordings, nil } diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 2ba84aab..8d8899ba 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -686,6 +686,8 @@ hey event add "Design review" --starts-on 2026-09-02 --start-time 14:00 --end-ti 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 hey event edit 4821 --title "Design review (moved)" +hey event edit 4821 --occurrence 4821_2026-09-15 --apply-to current --start-time 15:00 --json # That day alone +hey event edit 4821 --occurrence 4821_2026-09-15 --apply-to future --title "Design review (v2)" --allow-plain-notes --json hey event delete 4821 ``` @@ -712,6 +714,28 @@ served at all, so an edit removes one unless `--countdown` names it again. An ev cannot be read is refused rather than written blind — pass the day it starts (`hey event edit 4821 2026-09-02`) or `--calendar` to narrow the search. +**An id alone edits the whole series; one day of it is `--occurrence` plus `--apply-to`.** +`--occurrence` takes the `occurrence_id` from `day` or `week` exactly as served +(`_`, naming the series the positional id names) and `--apply-to` +is required with it: `current` changes that day alone, `future` changes that day and every +one after it — HEY's own two choices. `--apply-to` without `--occurrence`, any other value, +a malformed or mismatched occurrence id, or `--repeat`/`--repeat-until`/`--repeat-times` +with `current` are usage errors, refused before anything is read. The day is read on its +own date, so leave `[date]` out or name that day. A `future` edit makes HEY split the +series: the days from this one on get a new series id, and the answer is still the day +edited — read `day` or `week` again before editing the new series. + +An occurrence edit keeps everything it is not told to change — that day's own schedule, +zones, notes, location, link, attached email, reminders, circle and countdown, from the day +itself where HEY has already written it out — and refuses what it cannot keep: the +countdown is read back and re-sent (only `--countdown 0` removes it), and notes HEY serves +only as plain text, so an 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. HEY answers not-found for a date that is not a day of the series and for a +series you cannot edit alike. The JSON envelope is the one every mutation writes: +`summary` (`Occurrence updated` or `Occurrence and the following updated`) and `data` +holding the recording HEY answered. + An event with no `--start-time` is all-day; a `--start-time` with no `--end-time` runs an hour. Clock times are read in `--time-zone`, defaulting to the machine's zone. From a8866cad8751e92d0e9cf29d961c8fb800836ddc Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sun, 13 Sep 2026 16:04:34 -0400 Subject: [PATCH 2/4] Keep what an occurrence edit read back, and refuse what it could not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the occurrence edit found six ways a day could be written with something other than what it held, and one it cannot keep at all. A countdown exactly one day long is labelled "0 months before": HEY tries months before weeks before days and takes a remainder of up to a day as a match, which is every all-day event's one-day countdown. The parser took the zero and the SDK then sent nothing, which HEY reads as removal. The recording's own span says what a zero is, and any other zero stops the edit. A day that was moved is still listed under the date it stands for, but its own countdown ends where it now starts, so the identifier day never held it and the series' countdown was sent in its place. The moved-to day is read for it first. A clock time the zone springs over — 02:30 in New York on the second Sunday of March — is moved an hour on by ActiveSupport and an hour back by Go's time.Date, so a title-only edit would have moved the day. wallClockOn resolves it as HEY does. --calendar was both where the series was looked for and where the day was moved to, so a move to another calendar could never find its series. The day is read over every calendar now, and with --occurrence the flag is only the destination. A day HEY has written out carried its own id in `day` and `week` while its occurrence_id named the series, so the pair as served could not feed the edit. The row publishes the series id, as the docs already promised. --occurrence given empty, a script's unset variable, fell through to editing the whole series. It is a usage error now. An attached email the editor cannot read is left out of what HEY serves, and HEY clears the attachment whether the write sends an empty entry id or no key at all, so nothing client-side can keep it. The help and the docs say so instead. --- docs/cli.md | 25 ++- internal/cmd/events.go | 4 +- internal/cmd/events_occurrence.go | 148 +++++++++++---- internal/cmd/events_occurrence_test.go | 253 ++++++++++++++++++++++--- internal/cmd/events_period.go | 7 +- internal/cmd/events_period_test.go | 24 +++ skills/hey/SKILL.md | 11 +- 7 files changed, 400 insertions(+), 72 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index de9726fc..f6f77c95 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -430,14 +430,23 @@ 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. The countdown is read back from the recording HEY keeps for it -and sent again, so it survives unless `--countdown 0` removes it. 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. 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. +that day out on its own (a day like that lists under the series id in `day` and `week` +too, beside its `occurrence_id`, so the pair feeds the edit as served). 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. 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. The day is read over every calendar, so with `--occurrence` the `--calendar` flag is +only the calendar the day is moved to. 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 diff --git a/internal/cmd/events.go b/internal/cmd/events.go index 071807cf..bdb5575e 100644 --- a/internal/cmd/events.go +++ b/internal/cmd/events.go @@ -252,7 +252,9 @@ An occurrence edit keeps more than a whole-event edit does, and refuses what it keep. The countdown is read back and sent again, so it survives unless --countdown 0 removes it. 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.`, +--notes replaces them. The day is read over every calendar, so here --calendar is only +where the day is moved to. 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" diff --git a/internal/cmd/events_occurrence.go b/internal/cmd/events_occurrence.go index a3708945..ae8f6d9e 100644 --- a/internal/cmd/events_occurrence.go +++ b/internal/cmd/events_occurrence.go @@ -36,13 +36,19 @@ type occurrenceEdit struct { // schedule cannot apply to one day, which is HEY's rule as much as this command's. func (c *eventsEditCommand) parseOccurrence(cmd *cobra.Command, id int64, on string) (*occurrenceEdit, error) { flags := cmd.Flags() - if c.occurrence == "" { + if !flags.Changed("occurrence") { if flags.Changed("apply-to") { return nil, apierr.ErrUsageHint("--apply-to needs --occurrence", "hey event edit 4821 --occurrence 4821_2026-09-15 --apply-to current") } return nil, nil } + // An --occurrence given empty — a script's unset variable — must not quietly become an + // edit of the whole series, which is the one thing the flag was there to avoid. + if c.occurrence == "" { + return nil, apierr.ErrUsageHint("--occurrence needs an occurrence_id", + "an occurrence_id as hey event day serves it, _, for example 4821_2026-09-15") + } occurrence, err := hey.ParseOccurrenceID(c.occurrence) if err != nil || occurrence.String() != c.occurrence { @@ -113,6 +119,11 @@ func parseApplyTo(value string, given bool) (hey.OccurrenceScope, error) { // again, so only --countdown 0 removes it. Notes are served only as plain text, so an edit // that would send formatted notes back as text is refused unless --allow-plain-notes says // that is acceptable or --notes replaces them. +// +// One thing cannot be kept and cannot be refused either: an attached email the editor +// 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 key at all. That is the +// server's to fix; the docs say so. func (c *eventsEditCommand) editOccurrence(ctx context.Context, cmd *cobra.Command, edit occurrenceEdit) error { // The flags that need no read are refused first, so a bad one costs no request. repeat, err := c.fields.parseRepeat() @@ -123,7 +134,9 @@ func (c *eventsEditCommand) editOccurrence(ctx context.Context, cmd *cobra.Comma return err } - window, err := c.searchWindow(ctx, edit.occurrence.DateParam()) + // The day is read over every calendar, whatever --calendar says: here the flag names the + // calendar the day is moved to, and the series being moved is on some other one. + window, err := occurrenceDayWindow(ctx, edit.occurrence.Date) if err != nil { return err } @@ -216,6 +229,22 @@ func occurrenceWriteError(err error, occurrence hey.EventOccurrence) error { return apierr.FromSDK(err) } +// occurrenceDayWindow is the one day an occurrence is read on, [day, day+1), over every +// calendar the identity has. HEY answers that window with every series still recurring +// through it and with the day itself where an earlier edit has written it out, and it is +// one request per calendar, so there is nothing to narrow. +func occurrenceDayWindow(ctx context.Context, day time.Time) (recordingWindow, error) { + calendars, err := allCalendarIDs(ctx) + if err != nil { + return recordingWindow{}, err + } + return recordingWindow{ + calendars: calendars, + startsOn: day.Format(dateLayout), + endsOn: day.AddDate(0, 0, 1).Format(dateLayout), + }, nil +} + // occurrenceDay is what the day of an occurrence holds for it: the series it belongs to, // the day itself where HEY has already written it out as a recording of its own, and the // countdowns ending that day. @@ -305,9 +334,9 @@ func occurrenceInstants(series generated.Recording, day time.Time) (time.Time, t } wall := series.StartsAt.In(loc) named := day.Format(dateLayout) - start := time.Date(day.Year(), day.Month(), day.Day(), wall.Hour(), wall.Minute(), wall.Second(), 0, loc) + start := wallClockOn(day, wall, loc) for _, delta := range []int{0, -1, 1} { - candidate := time.Date(day.Year(), day.Month(), day.Day()+delta, wall.Hour(), wall.Minute(), wall.Second(), 0, loc) + candidate := wallClockOn(day.AddDate(0, 0, delta), wall, loc) if candidate.UTC().Format(dateLayout) == named { start = candidate break @@ -316,6 +345,22 @@ func occurrenceInstants(series generated.Recording, day time.Time) (time.Time, t return start, start.Add(duration) } +// wallClockOn is the series' clock time on a day, resolved the way HEY resolves it. A clock +// time that does not exist on that day — the hour a zone springs forward over — is moved +// an hour later and tried again, which is what ActiveSupport does when it changes the day +// of a time; Go's time.Date picks the earlier zone instead and would land the day an hour +// before HEY's, so a title-only edit would move it. +func wallClockOn(day, wall time.Time, loc *time.Location) time.Time { + hour, minute, second := wall.Clock() + for step := range 24 { + at := time.Date(day.Year(), day.Month(), day.Day(), hour+step, minute, second, 0, loc) + if h, m, _ := at.Clock(); h == (hour+step)%24 && m == minute { + return at + } + } + return time.Date(day.Year(), day.Month(), day.Day(), hour, minute, second, 0, loc) +} + // occurrenceCountdown is the countdown the write sends: the one --countdown names, or the // one the event already has, read back so that a write that says nothing about it does // not remove it. --countdown 0 is how it is removed on purpose. @@ -330,34 +375,54 @@ func (c *eventsEditCommand) occurrenceCountdown(ctx context.Context, cmd *cobra. } if day.realized != nil { if countdown, ok := countdownOf(day.countdowns, day.realized.Id); ok { - return countdownFromLabel(countdown.Label) + return countdownFromRecording(countdown) + } + // A day that was moved is still listed under the date it stands for, but its + // countdown ends where it now starts, so that day is where its own countdown is. + if day.realized.StartsAt.UTC().Format(dateLayout) != day.occurrence.DateParam() { + countdown, found, err := countdownEnding(ctx, window, *day.realized) + if err != nil || found { + return countdown, err + } } } if countdown, ok := countdownOf(day.countdowns, day.series.Id); ok { - return countdownFromLabel(countdown.Label) + return countdownFromRecording(countdown) } - - if day.series.StartsAt.IsZero() { + if day.series.StartsAt.UTC().Format(dateLayout) == day.occurrence.DateParam() { return hey.CountdownParams{}, nil } - began := day.series.StartsAt.UTC() - startsOn := began.Format(dateLayout) - if startsOn == day.occurrence.DateParam() { - return hey.CountdownParams{}, nil + countdown, _, err := countdownEnding(ctx, window, day.series) + return countdown, err +} + +// countdownEnding reads the day an event starts on, over its own calendar, for the +// countdown ending there. A countdown ends at the moment its event starts, so that one +// day is the whole window it can be found in. +func countdownEnding(ctx context.Context, window recordingWindow, event generated.Recording) (hey.CountdownParams, bool, error) { + if event.StartsAt.IsZero() { + return hey.CountdownParams{}, false, nil } calendars := window.calendars - if day.series.Calendar.Id != 0 { - calendars = []int64{day.series.Calendar.Id} + if event.Calendar.Id != 0 { + calendars = []int64{event.Calendar.Id} + } + starts := event.StartsAt.UTC() + startDay := recordingWindow{ + calendars: calendars, + startsOn: starts.Format(dateLayout), + endsOn: starts.AddDate(0, 0, 1).Format(dateLayout), } - firstDay := recordingWindow{calendars: calendars, startsOn: startsOn, endsOn: began.AddDate(0, 0, 1).Format(dateLayout)} - countdowns, err := firstDay.readTypes(ctx, recordingTypeCountdown) + countdowns, err := startDay.readTypes(ctx, recordingTypeCountdown) if err != nil { - return hey.CountdownParams{}, err + return hey.CountdownParams{}, false, err } - if countdown, ok := countdownOf(countdowns, day.series.Id); ok { - return countdownFromLabel(countdown.Label) + countdown, ok := countdownOf(countdowns, event.Id) + if !ok { + return hey.CountdownParams{}, false, nil } - return hey.CountdownParams{}, nil + params, err := countdownFromRecording(countdown) + return params, true, err } // countdownOf is the countdown recording under the event with that id, if the rows hold one. @@ -374,25 +439,38 @@ func countdownOf(rows []generated.Recording, eventID int64) (generated.Recording // before", from which the value and the unit its own form would submit are read back. var countdownLabel = regexp.MustCompile(`^(\d+) (day|week|month)s? before$`) -// countdownFromLabel reads the countdown back out of its label. A label in a shape this -// cannot read is refused rather than guessed at or dropped: the caller can still name the -// countdown with --countdown, or remove it with --countdown 0. -func countdownFromLabel(label string) (hey.CountdownParams, error) { - match := countdownLabel.FindStringSubmatch(label) +// countdownFromRecording reads the countdown back out of the recording HEY keeps for it. +// The label is the length as HEY's own form would show it, and it is trusted first. HEY +// tries months before weeks before days and takes a remainder of up to a day as a match, +// so a countdown that is exactly one day long — a day before an event at midnight, which +// is any all-day event's — comes out as "0 months before"; the recording's own span says +// what that is. Anything else this cannot read is refused rather than guessed at or +// dropped, since a countdown the write does not name is a countdown removed: the caller +// can still name it with --countdown, or remove it with --countdown 0. +func countdownFromRecording(countdown generated.Recording) (hey.CountdownParams, error) { + unreadable := &apierr.Error{ + Code: apierr.CodeAPI, + Message: fmt.Sprintf("the event's countdown could not be read back: %q", terminal.SanitizeLine(countdown.Label)), + Hint: "pass --countdown with --countdown-unit to keep it, or --countdown 0 to remove it", + } + + match := countdownLabel.FindStringSubmatch(countdown.Label) if match == nil { - return hey.CountdownParams{}, &apierr.Error{ - Code: apierr.CodeAPI, - Message: fmt.Sprintf("the event's countdown could not be read back: %q", terminal.SanitizeLine(label)), - Hint: "pass --countdown with --countdown-unit to keep it, or --countdown 0 to remove it", - } + return hey.CountdownParams{}, unreadable } value, _ := strconv.Atoi(match[1]) - units := map[string]hey.CountdownUnit{ - "day": hey.CountdownUnitDays, - "week": hey.CountdownUnitWeeks, - "month": hey.CountdownUnitMonths, + if value >= 1 { + units := map[string]hey.CountdownUnit{ + "day": hey.CountdownUnitDays, + "week": hey.CountdownUnitWeeks, + "month": hey.CountdownUnitMonths, + } + return hey.CountdownParams{Value: value, Unit: units[match[2]]}, nil + } + if !countdown.StartsAt.IsZero() && countdown.EndsAt.Sub(countdown.StartsAt) == 24*time.Hour { + return hey.CountdownParams{Value: 1, Unit: hey.CountdownUnitDays}, nil } - return hey.CountdownParams{Value: value, Unit: units[match[2]]}, nil + return hey.CountdownParams{}, unreadable } // errPlainNotes is how an occurrence edit refuses to flatten notes it was not asked to diff --git a/internal/cmd/events_occurrence_test.go b/internal/cmd/events_occurrence_test.go index 80d867c1..ef4f8bcb 100644 --- a/internal/cmd/events_occurrence_test.go +++ b/internal/cmd/events_occurrence_test.go @@ -254,6 +254,8 @@ func TestEventsEditOccurrenceRefusesWhatItCannotMean(t *testing.T) { }{ {name: "apply-to without occurrence", args: []string{"--apply-to", "current"}, want: "--apply-to needs --occurrence"}, {name: "occurrence without apply-to", args: []string{"--occurrence", "4821_2026-09-15"}, want: "--apply-to is required with --occurrence"}, + {name: "occurrence given empty", args: []string{"--occurrence=", "--title", "Design review (moved)"}, want: "--occurrence needs an occurrence_id"}, + {name: "occurrence given empty with a scope", args: []string{"--occurrence", "", "--apply-to", "current"}, want: "--occurrence needs an occurrence_id"}, {name: "unknown scope", args: []string{"--occurrence", "4821_2026-09-15", "--apply-to", "all"}, want: "invalid apply-to: all"}, {name: "no underscore", args: []string{"--occurrence", "4821-2026-09-15", "--apply-to", "current"}, want: "invalid occurrence: 4821-2026-09-15"}, {name: "date without dashes", args: []string{"--occurrence", "4821_20260915", "--apply-to", "current"}, want: "invalid occurrence: 4821_20260915"}, @@ -564,6 +566,134 @@ func TestEventsEditWholeSeriesIsUnchangedByTheOccurrenceFlags(t *testing.T) { } } +// recordingsServer answers a calendar list and a table of recordings reads keyed by +// " ", for the edits whose reads the day-and-first-day helper +// does not describe. A read with no entry is an error, so every request is accounted for. +func recordingsServer(t *testing.T, calendars string, reads map[string]string, patchPath string, onPatch func(t *testing.T, form url.Values)) (http.Handler, *atomic.Int32) { + t.Helper() + var writes atomic.Int32 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, calendars) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/calendars/") && strings.HasSuffix(r.URL.Path, "/recordings.json"): + calendar := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendars/"), "/recordings.json") + key := calendar + " " + r.URL.Query().Get("starts_on") + body, ok := reads[key] + if !ok { + t.Errorf("unexpected read of calendar %s from %s", calendar, r.URL.Query().Get("starts_on")) + body = `{}` + } + _, _ = io.WriteString(w, body) + case r.Method == http.MethodPatch && r.URL.Path == patchPath: + writes.Add(1) + onPatch(t, eventForm(t, r)) + _, _ = io.WriteString(w, `{"id":9001,"parent_id":4821}`) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL) + http.NotFound(w, r) + } + }), &writes +} + +const oneCalendarJSON = `{"calendars":[{"calendar":{"id":9,"name":"Work","owned":true}}]}` + +// A day that was moved is still listed under the date it stands for, while its own +// countdown ends where it now starts. Its countdown is what the edit keeps — read from the +// day it moved to — and not the series'. +func TestEventsEditOccurrenceKeepsAMovedDaysOwnCountdown(t *testing.T) { + moved := `{"id":9001,"type":"Calendar::Event","parent_id":4821,"occurrence_id":"4821_2026-09-15",` + + `"title":"Design review (with the vendor)","starts_at":"2026-09-18T13:30:00Z","ends_at":"2026-09-18T14:30:00Z",` + + `"starts_at_time_zone":"Europe/Zagreb","ends_at_time_zone":"Europe/Zagreb","calendar":{"id":9,"name":"Work"}}` + own := `{"id":78,"type":"Calendar::Countdown","parent_id":9001,"label":"2 days before",` + + `"starts_at":"2026-09-16T00:00:00Z","ends_at":"2026-09-18T13:30:00Z","calendar":{"id":9,"name":"Work"}}` + handler, writes := recordingsServer(t, oneCalendarJSON, map[string]string{ + "9 2026-09-15": `{"Calendar::Event":[` + occurrenceSeriesJSON + `,` + moved + `]}`, + "9 2026-09-18": `{"Calendar::Event":[` + moved + `],"Calendar::Countdown":[` + own + `]}`, + }, "/calendar/events/4821/occurrences/2026-09-15.json", func(t *testing.T, form url.Values) { + if got := form.Get("countdown_interval_duration_value"); got != "2" { + t.Errorf("countdown value = %q, want the moved day's own", got) + } + if got := form.Get("countdown_interval_duration_unit"); got != "86400" { + t.Errorf("countdown unit = %q, want days", got) + } + if got := form.Get("calendar_event[starts_at]"); got != "2026-09-18" { + t.Errorf("starts_at = %q, want the day where it was moved to", got) + } + }) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", "--title", "Design review (vendor, final)") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } + if writes.Load() != 1 { + t.Errorf("writes = %d, want one", writes.Load()) + } +} + +// A weekly series at 02:30 in New York has no 02:30 on the day the clocks spring forward. +// HEY draws that day at 03:30, so that is what a title-only edit sends back, whichever +// scope it reaches — anything else would move the day. +func TestEventsEditOccurrenceKeepsTheClockAcrossASpringForward(t *testing.T) { + series := `{"id":4821,"type":"Calendar::Event","title":"Early standup","recurring":true,` + + `"starts_at":"2026-03-01T07:30:00Z","ends_at":"2026-03-01T08:30:00Z",` + + `"starts_at_time_zone":"America/New_York","ends_at_time_zone":"America/New_York","calendar":{"id":9,"name":"Work"}}` + for _, scope := range []string{"current", "future"} { + t.Run(scope, func(t *testing.T) { + handler, _ := recordingsServer(t, oneCalendarJSON, map[string]string{ + "9 2026-03-08": `{"Calendar::Event":[` + series + `]}`, + "9 2026-03-01": `{"Calendar::Event":[` + series + `]}`, + }, "/calendar/events/4821/occurrences/2026-03-08.json", func(t *testing.T, form url.Values) { + if got := form.Get("calendar_event[starts_at]"); got != "2026-03-08" { + t.Errorf("starts_at = %q", got) + } + if got := form.Get("calendar_event[starts_at_time]"); got != "03:30:00" { + t.Errorf("starts_at_time = %q, want the hour HEY moved it to", got) + } + if got := form.Get("calendar_event[ends_at_time]"); got != "04:30:00" { + t.Errorf("ends_at_time = %q", got) + } + if got := form.Get("calendar_event[starts_at_time_zone_name]"); got != "America/New_York" { + t.Errorf("starts_at_time_zone_name = %q", got) + } + }) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-03-08", "--apply-to", scope, "--title", "Early standup (moved)") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } + }) + } +} + +// --calendar on an occurrence edit is where the day goes, not where the series is looked +// for: the day is read over every calendar, and the write carries the destination. +func TestEventsEditOccurrenceMovesADayToAnotherCalendar(t *testing.T) { + handler, writes := recordingsServer(t, + `{"calendars":[{"calendar":{"id":9,"name":"Work","owned":true}},{"calendar":{"id":10,"name":"Shared","owned":true}}]}`, + map[string]string{ + "9 2026-09-15": `{"Calendar::Event":[` + occurrenceSeriesJSON + `]}`, + "10 2026-09-15": `{}`, + "9 2026-09-01": `{}`, + }, "/calendar/events/4821/occurrences/2026-09-15.json", func(t *testing.T, form url.Values) { + if got := form.Get("calendar_event[calendar_id]"); got != "10" { + t.Errorf("calendar_id = %q, want the destination", got) + } + if got := form.Get("apply_to_future"); got != "1" { + t.Errorf("apply_to_future = %q", got) + } + }) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "future", "--calendar", "10", "--allow-plain-notes") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } + if writes.Load() != 1 { + t.Errorf("writes = %d, want one", writes.Load()) + } +} + // HEY names a day by the UTC date of its start and gives it the series' wall-clock time in // the series' zone, so the wall-clock day can be the day either side of the one named. func TestOccurrenceInstants(t *testing.T) { @@ -616,6 +746,18 @@ func TestOccurrenceInstants(t *testing.T) { series: timed("2026-09-01T09:00:00Z", "2026-09-01T09:30:00Z", ""), day: "2026-09-15", start: "2026-09-15T09:00:00Z", end: "2026-09-15T09:30:00Z", }, + { + // 02:30 does not exist in New York on 2026-03-08; HEY moves it an hour on, to + // 03:30 EDT, and Go's time.Date would have put it at 01:30 EST. + name: "a clock time the zone springs over", + series: timed("2026-03-01T07:30:00Z", "2026-03-01T08:30:00Z", "America/New_York"), + day: "2026-03-08", start: "2026-03-08T07:30:00Z", end: "2026-03-08T08:30:00Z", + }, + { + name: "the same clock time on an ordinary day", + series: timed("2026-03-01T07:30:00Z", "2026-03-01T08:30:00Z", "America/New_York"), + day: "2026-03-15", start: "2026-03-15T06:30:00Z", end: "2026-03-15T07:30:00Z", + }, { name: "two all-day days", series: generated.Recording{AllDay: true, @@ -636,36 +778,103 @@ func TestOccurrenceInstants(t *testing.T) { } } -func TestCountdownFromLabel(t *testing.T) { +// HEY labels a countdown by trying months, then weeks, then days, taking a remainder of up +// to a day as a match — so a countdown exactly one day long is "0 months before", and only +// the recording's own span says what it is. +func TestCountdownFromRecording(t *testing.T) { + span := func(hours int) (time.Time, time.Time) { + end := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + return end.Add(-time.Duration(hours) * time.Hour), end + } tests := []struct { + name string label string + hours int value int unit hey.CountdownUnit invalid bool }{ - {label: "3 weeks before", value: 3, unit: hey.CountdownUnitWeeks}, - {label: "1 days before", value: 1, unit: hey.CountdownUnitDays}, - {label: "1 day before", value: 1, unit: hey.CountdownUnitDays}, - {label: "6 months before", value: 6, unit: hey.CountdownUnitMonths}, - {label: "Countdown", invalid: true}, - {label: "", invalid: true}, - {label: "3 fortnights before", invalid: true}, - {label: "three weeks before", invalid: true}, + {name: "weeks", label: "3 weeks before", hours: 21 * 24, value: 3, unit: hey.CountdownUnitWeeks}, + {name: "days, plural", label: "1 days before", hours: 36, value: 1, unit: hey.CountdownUnitDays}, + {name: "day", label: "1 day before", hours: 36, value: 1, unit: hey.CountdownUnitDays}, + {name: "months", label: "6 months before", hours: 183 * 24, value: 6, unit: hey.CountdownUnitMonths}, + {name: "one day on an all-day event", label: "0 months before", hours: 24, value: 1, unit: hey.CountdownUnitDays}, + {name: "zero with an unexplained span", label: "0 months before", hours: 20, invalid: true}, + {name: "zero with no span", label: "0 months before", invalid: true}, + {name: "no countdown", label: "Countdown", hours: 24, invalid: true}, + {name: "empty", label: "", hours: 24, invalid: true}, + {name: "unknown unit", label: "3 fortnights before", hours: 42 * 24, invalid: true}, + {name: "words", label: "three weeks before", hours: 21 * 24, invalid: true}, } for _, tt := range tests { - countdown, err := countdownFromLabel(tt.label) - if tt.invalid { - if err == nil { - t.Errorf("countdownFromLabel(%q) = %+v, want an error", tt.label, countdown) + t.Run(tt.name, func(t *testing.T) { + recording := generated.Recording{Type: recordingTypeCountdown, Label: tt.label} + if tt.hours > 0 { + recording.StartsAt, recording.EndsAt = span(tt.hours) + } + countdown, err := countdownFromRecording(recording) + if tt.invalid { + if err == nil { + t.Fatalf("countdownFromRecording(%q) = %+v, want an error", tt.label, countdown) + } + return } - continue - } - if err != nil { - t.Errorf("countdownFromLabel(%q): %v", tt.label, err) - continue - } - if countdown.Value != tt.value || countdown.Unit != tt.unit { - t.Errorf("countdownFromLabel(%q) = %+v, want %d %d", tt.label, countdown, tt.value, tt.unit) - } + if err != nil { + t.Fatalf("countdownFromRecording(%q): %v", tt.label, err) + } + if countdown.Value != tt.value || countdown.Unit != tt.unit { + t.Errorf("countdownFromRecording(%q) = %+v, want %d %d", tt.label, countdown, tt.value, tt.unit) + } + }) + } +} + +// A countdown labelled "0 months before" whose span does not say it is one day is not +// guessed at: the edit stops before writing, as with any label it cannot read. +func TestEventsEditOccurrenceFailsClosedOnAZeroCountdown(t *testing.T) { + zero := strings.Replace(occurrenceCountdownJSON, "3 weeks before", "0 months before", 1) + handler, writes := occurrenceServer(t, "2026-09-15", + `{"Calendar::Event":[`+occurrenceSeriesJSON+`]}`, + `{"Calendar::Countdown":[`+zero+`]}`, + func(t *testing.T, form url.Values) {}) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", "--title", "Design review (moved)", "--allow-plain-notes") + var cliErr *apierr.Error + if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeAPI || !strings.Contains(cliErr.Message, `"0 months before"`) { + t.Fatalf("error = %v, want the countdown refusal", err) + } + if writes.Load() != 0 { + t.Errorf("writes = %d, want none", writes.Load()) + } +} + +// A one-day countdown on an all-day series is "0 months before" with a span of exactly a +// day, and it is sent back as the day it is. +func TestEventsEditOccurrenceKeepsAOneDayCountdown(t *testing.T) { + allDay := `{"id":4821,"type":"Calendar::Event","title":"Sarah's birthday","recurring":true,"all_day":true,` + + `"starts_at":"2026-09-01T00:00:00Z","ends_at":"2026-09-01T00:00:00Z","calendar":{"id":9,"name":"Work"}}` + oneDay := `{"id":77,"type":"Calendar::Countdown","parent_id":4821,"label":"0 months before",` + + `"starts_at":"2026-08-31T00:00:00Z","ends_at":"2026-09-01T00:00:00Z","calendar":{"id":9,"name":"Work"}}` + handler, _ := occurrenceServer(t, "2027-09-01", + `{"Calendar::Event":[`+allDay+`]}`, + `{"Calendar::Countdown":[`+oneDay+`]}`, + func(t *testing.T, form url.Values) { + if got := form.Get("countdown_interval_duration_value"); got != "1" { + t.Errorf("countdown value = %q, want one", got) + } + if got := form.Get("countdown_interval_duration_unit"); got != "86400" { + t.Errorf("countdown unit = %q, want days", got) + } + if got := form.Get("calendar_event[all_day]"); got != "1" { + t.Errorf("all_day = %q", got) + } + if got := form.Get("calendar_event[starts_at]"); got != "2027-09-01" { + t.Errorf("starts_at = %q", got) + } + }) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2027-09-01", "--apply-to", "current", "--title", "Sarah's birthday (party)") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) } } diff --git a/internal/cmd/events_period.go b/internal/cmd/events_period.go index 519c1575..93d63e46 100644 --- a/internal/cmd/events_period.go +++ b/internal/cmd/events_period.go @@ -151,10 +151,13 @@ 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. +// 'hey event edit' and 'hey event delete' take the series, so the row carries it. A day an +// earlier edit or a reminder has written out carries an id of its own, and it is still one +// day of its series: its occurrence_id names the series, and `hey event edit --occurrence` +// takes the series id beside it, so it is the series id the row publishes too. func resolveOccurrenceSeries(events []generated.Recording) { for i := range events { - if events[i].Id == 0 && events[i].ParentId != 0 { + if events[i].ParentId != 0 && (events[i].Id == 0 || events[i].OccurrenceId != "") { events[i].Id = events[i].ParentId } } diff --git a/internal/cmd/events_period_test.go b/internal/cmd/events_period_test.go index fb5209e7..7e84988c 100644 --- a/internal/cmd/events_period_test.go +++ b/internal/cmd/events_period_test.go @@ -53,6 +53,30 @@ func TestEventsDayExpandsRecurringEvents(t *testing.T) { } } +// A day an earlier edit has written out carries an id of its own, but it is still one day +// of its series, and the row publishes the series id beside the occurrence_id so that the +// pair feeds `hey event edit --occurrence` as served. +func TestEventsDayPublishesTheSeriesForAWrittenOutDay(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-15T00:00:00Z","ends_at":"2026-09-15T23:59:59Z","recordings":{`+ + `"Calendar::Event":[`+ + `{"id":9001,"parent_id":4821,"occurrence_id":"4821_2026-09-15","title":"Design review (with the vendor)","starts_at":"2026-09-15T13:30:00Z","ends_at":"2026-09-15T14:30:00Z","type":"Calendar::Event","calendar":{"id":9,"name":"Work"}}`+ + `]}}`) + }), "event", "day", "2026-09-15") + 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 event", response.Data) + } + row, ok := events[0].(map[string]any) + if !ok || row["id"] != float64(4821) || row["occurrence_id"] != "4821_2026-09-15" { + t.Errorf("row = %#v, want the series id beside the occurrence_id", events[0]) + } +} + 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" { diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 8d8899ba..c8c068c8 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -731,10 +731,13 @@ itself where HEY has already written it out — and refuses what it cannot keep: countdown is read back and re-sent (only `--countdown 0` removes it), and notes HEY serves only as plain text, so an 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. HEY answers not-found for a date that is not a day of the series and for a -series you cannot edit alike. The JSON envelope is the one every mutation writes: -`summary` (`Occurrence updated` or `Occurrence and the following updated`) and `data` -holding the recording HEY answered. +needs neither. With `--occurrence`, `--calendar` is only the calendar the day moves to: +the day is read over every calendar. An attached email you cannot read is not served and +is detached by any edit, whole event or one day — nothing client-side can keep it. HEY +answers not-found for a date that is not a day of the series and for a series you cannot +edit alike. The JSON envelope is the one every mutation writes: `summary` (`Occurrence +updated` or `Occurrence and the following updated`) and `data` holding the recording HEY +answered. An event with no `--start-time` is all-day; a `--start-time` with no `--end-time` runs an hour. Clock times are read in `--time-zone`, defaulting to the machine's zone. From 9a644ef4ade8fe1d67a7608f0a7b5451cce895e5 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 14 Sep 2026 07:35:16 -0400 Subject: [PATCH 3/4] Leave a written-out day its own id, and refuse what a future edit would undo A second review of the occurrence edit found four more ways a write could reach something other than what was asked. A day HEY has written out has an id of its own, and HEY's event routes act on that id for that day alone. The last commit rewrote it to the series id in `day` and `week`, so the same `edit ` or `delete ` that had changed one day would have reached the whole series. It keeps its id again; parent_id names the series, and the docs say that is what --occurrence takes beside the occurrence_id. A future edit records the new series on the series' calendar unless told otherwise, so a day that had been moved to another calendar would have moved back with everything after it. The write names the day's calendar when --calendar does not. A future edit records the new series from the series' guest list and invites it, so a day that had come to have guests of its own would have lost them, and the series' guests would have been invited to it. That is refused until --invite says whose list the new series gets; the day alone keeps its own list as before. HEY shows one day of a series the series' countdown whenever the day has none of its own, and a write that names no countdown removes only the day's own, so --countdown 0 on one day of a series with a countdown was reported done and changed nothing. It is refused with where the countdown can come off: a future edit, or the series itself. --- docs/cli.md | 28 ++-- internal/cmd/events.go | 16 +- internal/cmd/events_occurrence.go | 92 +++++++++++- internal/cmd/events_occurrence_test.go | 193 +++++++++++++++++++++++-- internal/cmd/events_period.go | 12 +- internal/cmd/events_period_test.go | 13 +- skills/hey/SKILL.md | 26 ++-- 7 files changed, 321 insertions(+), 59 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index f6f77c95..454d90c3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -430,18 +430,26 @@ 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 under the series id in `day` and `week` -too, beside its `occurrence_id`, so the pair feeds the edit as served). 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. 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 +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 ` and `hey event delete ` 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. The day is read over every calendar, so with `--occurrence` the `--calendar` flag is -only the calendar the day is moved to. 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. +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 diff --git a/internal/cmd/events.go b/internal/cmd/events.go index bdb5575e..032a2fb6 100644 --- a/internal/cmd/events.go +++ b/internal/cmd/events.go @@ -250,11 +250,17 @@ 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. 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. The day is read over every calendar, so here --calendar is only -where the day is moved to. 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.`, +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" diff --git a/internal/cmd/events_occurrence.go b/internal/cmd/events_occurrence.go index ae8f6d9e..d63c6b5a 100644 --- a/internal/cmd/events_occurrence.go +++ b/internal/cmd/events_occurrence.go @@ -6,6 +6,7 @@ import ( "net/http" "regexp" "strconv" + "strings" "time" "github.com/spf13/cobra" @@ -153,6 +154,13 @@ func (c *eventsEditCommand) editOccurrence(ctx context.Context, cmd *cobra.Comma if event.Description != "" && !cmd.Flags().Changed("notes") && !c.allowPlainNotes { return errPlainNotes(edit.occurrence) } + // A future edit records the new series from the series' own guest list and invites it, + // whatever this day's had come to be, and a guest list is only ever sent on purpose. + if edit.scope == hey.OccurrenceScopeThisAndFollowing && day.realized != nil && !cmd.Flags().Changed("invite") { + if guests := attendeeAddresses(day.realized.Attendances); !sameAddresses(guests, attendeeAddresses(day.series.Attendances)) { + return errDayGuests(edit.occurrence, guests) + } + } schedule, err := c.fields.scheduleFrom(cmd, event) if err != nil { @@ -162,7 +170,7 @@ func (c *eventsEditCommand) editOccurrence(ctx context.Context, cmd *cobra.Comma if err != nil { return err } - countdown, err := c.occurrenceCountdown(ctx, cmd, window, day) + countdown, err := c.occurrenceCountdown(ctx, cmd, window, day, edit.scope) if err != nil { return err } @@ -191,6 +199,11 @@ func (c *eventsEditCommand) editOccurrence(ctx context.Context, cmd *cobra.Comma } if cmd.Flags().Changed("calendar") { changes.CalendarID = &c.fields.calendar + } else if edit.scope == hey.OccurrenceScopeThisAndFollowing && day.realized != nil && day.realized.Calendar.Id != 0 { + // HEY records the new series on the series' calendar unless told otherwise, so a day + // that had been moved to another calendar would move back with everything after it. + calendarID := day.realized.Calendar.Id + changes.CalendarID = &calendarID } // The circle is sent back whether or not it changes. A future edit records a new series // for the days from this one on, and HEY circles that one only when told to; the @@ -369,9 +382,27 @@ func wallClockOn(day, wall time.Time, loc *time.Location) time.Time { // day written out on its own may carry one, ending on this day; the series' ends on the // day the series began, which is this day only for the first occurrence and otherwise one // more read of that one day, on the series' own calendar. -func (c *eventsEditCommand) occurrenceCountdown(ctx context.Context, cmd *cobra.Command, window recordingWindow, day occurrenceDay) (hey.CountdownParams, error) { +// +// Removing one is the other way round. HEY shows one day of a series the series' countdown +// whenever the day has none of its own, and a write that names no countdown removes only +// the day's own; so --countdown 0 on one day of a series with a countdown would be +// reported done and change nothing. That is refused: the countdown comes off with the +// series, by --apply-to future or an edit of the series itself. +func (c *eventsEditCommand) occurrenceCountdown(ctx context.Context, cmd *cobra.Command, window recordingWindow, day occurrenceDay, scope hey.OccurrenceScope) (hey.CountdownParams, error) { if cmd.Flags().Changed("countdown") { - return c.fields.parseCountdown() + countdown, err := c.fields.parseCountdown() + if err != nil || countdown.Value != 0 || scope != hey.OccurrenceScopeThisEvent { + return countdown, err + } + if _, inherited, err := seriesCountdown(ctx, window, day); err != nil || inherited { + if err != nil { + return hey.CountdownParams{}, err + } + return hey.CountdownParams{}, apierr.ErrUsageHint( + fmt.Sprintf("the series has a countdown, and HEY shows it on %s whatever the day's own", day.occurrence), + "--apply-to future removes it from this day and every one after it; an edit of the series id alone removes it everywhere") + } + return hey.CountdownParams{}, nil } if day.realized != nil { if countdown, ok := countdownOf(day.countdowns, day.realized.Id); ok { @@ -386,14 +417,21 @@ func (c *eventsEditCommand) occurrenceCountdown(ctx context.Context, cmd *cobra. } } } + countdown, _, err := seriesCountdown(ctx, window, day) + return countdown, err +} + +// seriesCountdown is the series' own countdown: on the day, where the day is the one the +// series began on, and otherwise on that first day in one more read. +func seriesCountdown(ctx context.Context, window recordingWindow, day occurrenceDay) (hey.CountdownParams, bool, error) { if countdown, ok := countdownOf(day.countdowns, day.series.Id); ok { - return countdownFromRecording(countdown) + params, err := countdownFromRecording(countdown) + return params, true, err } if day.series.StartsAt.UTC().Format(dateLayout) == day.occurrence.DateParam() { - return hey.CountdownParams{}, nil + return hey.CountdownParams{}, false, nil } - countdown, _, err := countdownEnding(ctx, window, day.series) - return countdown, err + return countdownEnding(ctx, window, day.series) } // countdownEnding reads the day an event starts on, over its own calendar, for the @@ -473,6 +511,46 @@ func countdownFromRecording(countdown generated.Recording) (hey.CountdownParams, return hey.CountdownParams{}, unreadable } +// attendeeAddresses is a guest list as the set of addresses on it, which is how two lists +// are told apart: the same guests in another order or with another status are one list. +func attendeeAddresses(attendances []generated.Attendance) []string { + addresses := make([]string, 0, len(attendances)) + for _, attendance := range attendances { + if address := strings.ToLower(strings.TrimSpace(attendance.EmailAddress)); address != "" { + addresses = append(addresses, address) + } + } + return addresses +} + +func sameAddresses(a, b []string) bool { + if len(a) != len(b) { + return false + } + seen := make(map[string]int, len(a)) + for _, address := range a { + seen[address]++ + } + for _, address := range b { + if seen[address] == 0 { + return false + } + seen[address]-- + } + return true +} + +// errDayGuests is how a future edit refuses to invite the series' guests to a day that had +// come to have guests of its own. HEY records the new series from the series' list and +// sends the invitations, so the caller has to say whose list the new series gets. +func errDayGuests(occurrence hey.EventOccurrence, guests []string) error { + return &apierr.Error{ + Code: apierr.CodeUsage, + Message: fmt.Sprintf("occurrence %s has a guest list of its own (%s), and a future edit would give the new series the series' list instead", occurrence, terminal.SanitizeLine(strings.Join(guests, ", "))), + Hint: "pass --invite for each address the new series should invite; the list replaces the series' and sends invitations", + } +} + // errPlainNotes is how an occurrence edit refuses to flatten notes it was not asked to // change. HEY serves them as plain text and takes back whatever it is sent, so nothing here // can tell formatted notes from plain ones; the caller has to say the loss is acceptable. diff --git a/internal/cmd/events_occurrence_test.go b/internal/cmd/events_occurrence_test.go index ef4f8bcb..e9e31434 100644 --- a/internal/cmd/events_occurrence_test.go +++ b/internal/cmd/events_occurrence_test.go @@ -382,24 +382,68 @@ func TestEventsEditOccurrenceRefusesADayItCannotRead(t *testing.T) { } } -// --countdown 0 is the one way an occurrence edit removes a countdown, and saying so means -// the countdown is not read back at all. -func TestEventsEditOccurrenceRemovesTheCountdownOnlyWhenTold(t *testing.T) { - handler, _ := occurrenceServer(t, "2026-09-15", - `{"Calendar::Event":[`+occurrenceSeriesJSON+`]}`, - "", - func(t *testing.T, form url.Values) { - for _, field := range []string{"countdown_interval_duration_value", "countdown_interval_duration_unit"} { - if form.Has(field) { - t.Errorf("%s = %q, want none", field, form.Get(field)) - } +// --countdown 0 is the one way an occurrence edit removes a countdown. On one day alone it +// can only remove the day's own: HEY shows a day the series' countdown whenever it has +// none of its own, so with a series that has one the removal would be reported and change +// nothing, and it is refused instead. A future edit takes the countdown off the new +// series, so there it goes through. +func TestEventsEditOccurrenceRemovesTheCountdownOnlyWhereItCan(t *testing.T) { + noCountdown := func(t *testing.T, form url.Values) { + for _, field := range []string{"countdown_interval_duration_value", "countdown_interval_duration_unit"} { + if form.Has(field) { + t.Errorf("%s = %q, want none", field, form.Get(field)) } - }) - _, err := runJSONCommand(t, handler, - "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", "--countdown", "0", "--allow-plain-notes") - if err != nil { - t.Fatalf("execute occurrence edit: %v", err) + } } + + t.Run("a series without one", func(t *testing.T) { + handler, writes := occurrenceServer(t, "2026-09-15", + `{"Calendar::Event":[`+occurrenceSeriesJSON+`]}`, + `{}`, + noCountdown) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", "--countdown", "0", "--allow-plain-notes") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } + if writes.Load() != 1 { + t.Errorf("writes = %d, want one", writes.Load()) + } + }) + + t.Run("one day of a series with one", func(t *testing.T) { + handler, writes := occurrenceServer(t, "2026-09-15", + `{"Calendar::Event":[`+occurrenceSeriesJSON+`]}`, + `{"Calendar::Countdown":[`+occurrenceCountdownJSON+`]}`, + noCountdown) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", "--countdown", "0", "--allow-plain-notes") + var cliErr *apierr.Error + if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage || !strings.Contains(cliErr.Message, "the series has a countdown") { + t.Fatalf("error = %v, want the refusal", err) + } + if !strings.Contains(cliErr.Hint, "--apply-to future") { + t.Errorf("hint = %q, want it to say where the countdown can come off", cliErr.Hint) + } + if writes.Load() != 0 { + t.Errorf("writes = %d, want none", writes.Load()) + } + }) + + t.Run("this day and the following", func(t *testing.T) { + handler, writes := occurrenceServer(t, "2026-09-15", + `{"Calendar::Event":[`+occurrenceSeriesJSON+`]}`, + "", + noCountdown) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "future", "--countdown", "0", "--allow-plain-notes") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } + if writes.Load() != 1 { + t.Errorf("writes = %d, want one", writes.Load()) + } + }) } // A countdown --countdown names replaces the one the series has, without reading it back. @@ -694,6 +738,123 @@ func TestEventsEditOccurrenceMovesADayToAnotherCalendar(t *testing.T) { } } +// A day that was moved to another calendar stays there through a future edit: HEY records +// the new series on the series' calendar unless told otherwise, so the write names the +// day's. --calendar still says where to move it instead. +func TestEventsEditOccurrenceFutureKeepsAMovedDaysCalendar(t *testing.T) { + twoCalendars := `{"calendars":[{"calendar":{"id":9,"name":"Work","owned":true}},{"calendar":{"id":10,"name":"Shared","owned":true}}]}` + moved := `{"id":9001,"type":"Calendar::Event","parent_id":4821,"occurrence_id":"4821_2026-09-15",` + + `"title":"Design review (with the vendor)","starts_at":"2026-09-15T12:00:00Z","ends_at":"2026-09-15T13:00:00Z",` + + `"starts_at_time_zone":"Europe/Zagreb","ends_at_time_zone":"Europe/Zagreb","calendar":{"id":10,"name":"Shared"}}` + reads := func() map[string]string { + return map[string]string{ + "9 2026-09-15": `{"Calendar::Event":[` + occurrenceSeriesJSON + `]}`, + "10 2026-09-15": `{"Calendar::Event":[` + moved + `]}`, + "9 2026-09-01": `{}`, + } + } + + t.Run("without --calendar", func(t *testing.T) { + handler, _ := recordingsServer(t, twoCalendars, reads(), "/calendar/events/4821/occurrences/2026-09-15.json", + func(t *testing.T, form url.Values) { + if got := form.Get("calendar_event[calendar_id]"); got != "10" { + t.Errorf("calendar_id = %q, want the day's own calendar", got) + } + }) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "future", "--title", "Design review (vendor, final)") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } + }) + + t.Run("with --calendar", func(t *testing.T) { + handler, _ := recordingsServer(t, twoCalendars, reads(), "/calendar/events/4821/occurrences/2026-09-15.json", + func(t *testing.T, form url.Values) { + if got := form.Get("calendar_event[calendar_id]"); got != "9" { + t.Errorf("calendar_id = %q, want the destination named", got) + } + }) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "future", "--calendar", "9", "--title", "Design review (vendor, final)") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } + }) + + t.Run("that day alone leaves the calendar unsaid", func(t *testing.T) { + handler, _ := recordingsServer(t, twoCalendars, reads(), "/calendar/events/4821/occurrences/2026-09-15.json", + func(t *testing.T, form url.Values) { + if form.Has("calendar_event[calendar_id]") { + t.Errorf("calendar_id = %q, want none: the day stays where it is", form.Get("calendar_event[calendar_id]")) + } + }) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", "--title", "Design review (vendor, final)") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } + }) +} + +// A future edit records the new series from the series' guest list and invites it. A day +// that had come to have guests of its own is therefore refused until --invite says whose +// list the new series gets; that day alone keeps its own list without a word. +func TestEventsEditOccurrenceFutureRefusesToDropADaysOwnGuests(t *testing.T) { + series := strings.Replace(occurrenceSeriesJSON, `"highlighted":true,`, + `"highlighted":true,"attendances":[{"id":1,"email_address":"alice@example.com","status":"accepted","name":"Alice Chen"}],`, 1) + ownGuests := `{"id":9001,"type":"Calendar::Event","parent_id":4821,"occurrence_id":"4821_2026-09-15",` + + `"title":"Design review (with the vendor)","starts_at":"2026-09-15T12:00:00Z","ends_at":"2026-09-15T13:00:00Z",` + + `"starts_at_time_zone":"Europe/Zagreb","ends_at_time_zone":"Europe/Zagreb",` + + `"attendances":[{"id":2,"email_address":"bob@example.org","status":"pending","name":"Bob Reyes"}],"calendar":{"id":9,"name":"Work"}}` + day := `{"Calendar::Event":[` + series + `,` + ownGuests + `]}` + + t.Run("refused", func(t *testing.T) { + handler, writes := occurrenceServer(t, "2026-09-15", day, "", func(t *testing.T, form url.Values) {}) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "future", "--title", "Design review (vendor, final)") + var cliErr *apierr.Error + if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage || !strings.Contains(cliErr.Message, "guest list of its own (bob@example.org)") { + t.Fatalf("error = %v, want the guest-list refusal", err) + } + if writes.Load() != 0 { + t.Errorf("writes = %d, want none", writes.Load()) + } + }) + + t.Run("with --invite", func(t *testing.T) { + handler, writes := occurrenceServer(t, "2026-09-15", day, `{}`, func(t *testing.T, form url.Values) { + if got := form["calendar_event[attendance_email_addresses][]"]; len(got) != 1 || got[0] != "bob@example.org" { + t.Errorf("attendees = %v, want Bob alone", got) + } + }) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "future", "--invite", "bob@example.org", "--title", "Design review (vendor, final)") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } + if writes.Load() != 1 { + t.Errorf("writes = %d, want one", writes.Load()) + } + }) + + t.Run("that day alone keeps its own", func(t *testing.T) { + handler, writes := occurrenceServer(t, "2026-09-15", day, `{}`, func(t *testing.T, form url.Values) { + if got := form["calendar_event[attendance_email_addresses][]"]; got != nil { + t.Errorf("attendees = %v, want none submitted", got) + } + }) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current", "--title", "Design review (vendor, final)") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } + if writes.Load() != 1 { + t.Errorf("writes = %d, want one", writes.Load()) + } + }) +} + // HEY names a day by the UTC date of its start and gives it the series' wall-clock time in // the series' zone, so the wall-clock day can be the day either side of the one named. func TestOccurrenceInstants(t *testing.T) { diff --git a/internal/cmd/events_period.go b/internal/cmd/events_period.go index 93d63e46..682e531d 100644 --- a/internal/cmd/events_period.go +++ b/internal/cmd/events_period.go @@ -151,13 +151,15 @@ 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. A day an -// earlier edit or a reminder has written out carries an id of its own, and it is still one -// day of its series: its occurrence_id names the series, and `hey event edit --occurrence` -// takes the series id beside it, so it is the series id the row publishes too. +// 'hey event edit' and 'hey event delete' take the series, so the row carries it. +// +// A day an earlier edit or a reminder has written out is different: it has an id of its +// own, and HEY's event routes act on that id for that day alone — an edit or a delete by +// it reaches nothing else. So it keeps its id, and parent_id names its series, which is +// what `hey event edit --occurrence` takes beside its occurrence_id. func resolveOccurrenceSeries(events []generated.Recording) { for i := range events { - if events[i].ParentId != 0 && (events[i].Id == 0 || events[i].OccurrenceId != "") { + if events[i].Id == 0 && events[i].ParentId != 0 { events[i].Id = events[i].ParentId } } diff --git a/internal/cmd/events_period_test.go b/internal/cmd/events_period_test.go index 7e84988c..b861bbf7 100644 --- a/internal/cmd/events_period_test.go +++ b/internal/cmd/events_period_test.go @@ -53,10 +53,11 @@ func TestEventsDayExpandsRecurringEvents(t *testing.T) { } } -// A day an earlier edit has written out carries an id of its own, but it is still one day -// of its series, and the row publishes the series id beside the occurrence_id so that the -// pair feeds `hey event edit --occurrence` as served. -func TestEventsDayPublishesTheSeriesForAWrittenOutDay(t *testing.T) { +// A day an earlier edit has written out has an id of its own, and HEY's event routes act +// on that id for that day alone, so the row keeps it: rewriting it to the series would +// turn an edit or a delete of one day into one of the whole series. The series is named +// by parent_id beside the occurrence_id. +func TestEventsDayKeepsAWrittenOutDaysOwnID(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-15T00:00:00Z","ends_at":"2026-09-15T23:59:59Z","recordings":{`+ @@ -72,8 +73,8 @@ func TestEventsDayPublishesTheSeriesForAWrittenOutDay(t *testing.T) { t.Fatalf("data = %#v, want the one event", response.Data) } row, ok := events[0].(map[string]any) - if !ok || row["id"] != float64(4821) || row["occurrence_id"] != "4821_2026-09-15" { - t.Errorf("row = %#v, want the series id beside the occurrence_id", events[0]) + if !ok || row["id"] != float64(9001) || row["parent_id"] != float64(4821) || row["occurrence_id"] != "4821_2026-09-15" { + t.Errorf("row = %#v, want the day's own id, with the series in parent_id", events[0]) } } diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index c8c068c8..c4ea701d 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -728,16 +728,22 @@ edited — read `day` or `week` again before editing the new series. An occurrence edit keeps everything it is not told to change — that day's own schedule, zones, notes, location, link, attached email, reminders, circle and countdown, from the day itself where HEY has already written it out — and refuses what it cannot keep: the -countdown is read back and re-sent (only `--countdown 0` removes it), and notes HEY serves -only as plain text, so an 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. With `--occurrence`, `--calendar` is only the calendar the day moves to: -the day is read over every calendar. An attached email you cannot read is not served and -is detached by any edit, whole event or one day — nothing client-side can keep it. HEY -answers not-found for a date that is not a day of the series and for a series you cannot -edit alike. The JSON envelope is the one every mutation writes: `summary` (`Occurrence -updated` or `Occurrence and the following updated`) and `data` holding the recording HEY -answered. +countdown is read back and re-sent (only `--countdown 0` removes it, and not from one day +of a series that has one, since HEY shows the day the series' countdown regardless — use +`future` or edit the series), and notes HEY serves only as plain text, so an 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 `future` edit builds the +new series from the series' own guest list and sends invitations, so a day whose guests +differ from the series' is refused until `--invite` names the new list. With +`--occurrence`, `--calendar` is only the calendar the day moves to: the day is read over +every calendar, and a day already moved elsewhere stays there. A day HEY has written out +lists in `day`/`week` with its own `id` (which `edit `/`delete ` act on for that +day alone) and the series in `parent_id`: use `parent_id` as the positional id with +`--occurrence`. An attached email you cannot read is not served and is detached by any +edit, whole event or one day — nothing client-side can keep it. HEY answers not-found for +a date that is not a day of the series and for a series you cannot edit alike. The JSON +envelope is the one every mutation writes: `summary` (`Occurrence updated` or `Occurrence +and the following updated`) and `data` holding the recording HEY answered. An event with no `--start-time` is all-day; a `--start-time` with no `--end-time` runs an hour. Clock times are read in `--time-zone`, defaulting to the machine's zone. From b2c85048bffdc1db3d5cb66b897445d52fbc7e2c Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 14 Sep 2026 07:54:37 -0400 Subject: [PATCH 4/4] Read a countdown's length off its span, and refuse a repeat count of nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HEY labels a countdown by trying months, then weeks, then days, and taking a remainder of up to a whole day as a match, so eight days at midnight is "1 weeks before" and 29 days is "4 weeks before" — and sending the label back shortened every one of them by a day. The recording's span is the countdown plus however far into its day the event starts, never a day more, so the length is read off the span now; the label settles the one thing the span cannot, days against HEY's month, which is not a whole number of them. The one-day case that used to be special is just a span of a day. --repeat-times 0 and a negative count meant "forever", since the parser only knew a positive count from the zero value. It knows the flag was given now, and refuses a count under one — on a create and a whole-event edit as well as on the future edit that would have split the series on it. The day and the week help said an occurrence's id is its series, which is true of a day HEY draws from the series and not of one it has written out, whose id is its own and whose series is parent_id. Both say so. --- docs/cli.md | 6 +- internal/cmd/events.go | 14 ++- internal/cmd/events_occurrence.go | 57 +++++++---- internal/cmd/events_occurrence_test.go | 128 +++++++++++++++++++++---- internal/cmd/events_period.go | 16 +++- skills/hey/SKILL.md | 6 +- 6 files changed, 177 insertions(+), 50 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 454d90c3..d2417f9a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -394,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. diff --git a/internal/cmd/events.go b/internal/cmd/events.go index 032a2fb6..1211732e 100644 --- a/internal/cmd/events.go +++ b/internal/cmd/events.go @@ -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 } @@ -316,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 } @@ -702,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, diff --git a/internal/cmd/events_occurrence.go b/internal/cmd/events_occurrence.go index d63c6b5a..bdefa10a 100644 --- a/internal/cmd/events_occurrence.go +++ b/internal/cmd/events_occurrence.go @@ -127,7 +127,7 @@ func parseApplyTo(value string, given bool) (hey.OccurrenceScope, error) { // server's to fix; the docs say so. func (c *eventsEditCommand) editOccurrence(ctx context.Context, cmd *cobra.Command, edit occurrenceEdit) error { // The flags that need no read are refused first, so a bad one costs no request. - repeat, err := c.fields.parseRepeat() + repeat, err := c.fields.parseRepeat(cmd) if err != nil { return err } @@ -478,13 +478,19 @@ func countdownOf(rows []generated.Recording, eventID int64) (generated.Recording var countdownLabel = regexp.MustCompile(`^(\d+) (day|week|month)s? before$`) // countdownFromRecording reads the countdown back out of the recording HEY keeps for it. -// The label is the length as HEY's own form would show it, and it is trusted first. HEY -// tries months before weeks before days and takes a remainder of up to a day as a match, -// so a countdown that is exactly one day long — a day before an event at midnight, which -// is any all-day event's — comes out as "0 months before"; the recording's own span says -// what that is. Anything else this cannot read is refused rather than guessed at or -// dropped, since a countdown the write does not name is a countdown removed: the caller -// can still name it with --countdown, or remove it with --countdown 0. +// +// The recording spans from the countdown's start to the moment the event starts, and its +// start is the event's midnight less the countdown, so the span is the countdown plus +// however far into its day the event starts: under a day more, never less. That span is +// exact. The label is not: HEY tries months, then weeks, then days, and takes a remainder +// of up to a whole day as a match, so eight days at midnight is "1 weeks before", 29 days +// is "4 weeks before", and one day is "0 months before" — and sending the label back would +// shorten every one of them. So the length is read off the span, and the label settles the +// one thing the span cannot: a month is not a whole number of days, so a span that fits +// both "N months" and "M days" is whichever the label says, which is what HEY's own form +// would resend. A recording this cannot read is refused rather than guessed at or dropped, +// since a countdown the write does not name is a countdown removed: the caller can still +// name it with --countdown, or remove it with --countdown 0. func countdownFromRecording(countdown generated.Recording) (hey.CountdownParams, error) { unreadable := &apierr.Error{ Code: apierr.CodeAPI, @@ -496,19 +502,34 @@ func countdownFromRecording(countdown generated.Recording) (hey.CountdownParams, if match == nil { return hey.CountdownParams{}, unreadable } - value, _ := strconv.Atoi(match[1]) - if value >= 1 { - units := map[string]hey.CountdownUnit{ - "day": hey.CountdownUnitDays, - "week": hey.CountdownUnitWeeks, - "month": hey.CountdownUnitMonths, + labelValue, _ := strconv.Atoi(match[1]) + labelUnit := match[2] + + span := countdown.EndsAt.Sub(countdown.StartsAt) + if countdown.StartsAt.IsZero() || countdown.EndsAt.IsZero() || span <= 0 { + // Nothing to measure: the label is all there is, and a zero on it says nothing. + if labelValue < 1 { + return hey.CountdownParams{}, unreadable } - return hey.CountdownParams{Value: value, Unit: units[match[2]]}, nil + units := map[string]hey.CountdownUnit{"day": hey.CountdownUnitDays, "week": hey.CountdownUnitWeeks, "month": hey.CountdownUnitMonths} + return hey.CountdownParams{Value: labelValue, Unit: units[labelUnit]}, nil + } + + const day = 24 * time.Hour + if labelUnit == "month" { + month := time.Duration(hey.CountdownUnitMonths) * time.Second + if months := int(span / month); months >= 1 && span-time.Duration(months)*month < day { + return hey.CountdownParams{Value: months, Unit: hey.CountdownUnitMonths}, nil + } + } + days := int(span / day) + if days < 1 { + return hey.CountdownParams{}, unreadable } - if !countdown.StartsAt.IsZero() && countdown.EndsAt.Sub(countdown.StartsAt) == 24*time.Hour { - return hey.CountdownParams{Value: 1, Unit: hey.CountdownUnitDays}, nil + if labelUnit == "week" && days%7 == 0 { + return hey.CountdownParams{Value: days / 7, Unit: hey.CountdownUnitWeeks}, nil } - return hey.CountdownParams{}, unreadable + return hey.CountdownParams{Value: days, Unit: hey.CountdownUnitDays}, nil } // attendeeAddresses is a guest list as the set of addresses on it, which is how two lists diff --git a/internal/cmd/events_occurrence_test.go b/internal/cmd/events_occurrence_test.go index e9e31434..3eaf9dcc 100644 --- a/internal/cmd/events_occurrence_test.go +++ b/internal/cmd/events_occurrence_test.go @@ -855,6 +855,83 @@ func TestEventsEditOccurrenceFutureRefusesToDropADaysOwnGuests(t *testing.T) { }) } +// An eight-day countdown on an all-day series is labelled "1 weeks before", and a title-only +// edit sends the eight days back, not the week. +func TestEventsEditOccurrenceKeepsAnEightDayCountdown(t *testing.T) { + allDay := `{"id":4821,"type":"Calendar::Event","title":"Sarah's birthday","recurring":true,"all_day":true,` + + `"starts_at":"2026-09-01T00:00:00Z","ends_at":"2026-09-01T00:00:00Z","calendar":{"id":9,"name":"Work"}}` + eightDays := `{"id":77,"type":"Calendar::Countdown","parent_id":4821,"label":"1 weeks before",` + + `"starts_at":"2026-08-24T00:00:00Z","ends_at":"2026-09-01T00:00:00Z","calendar":{"id":9,"name":"Work"}}` + handler, _ := occurrenceServer(t, "2027-09-01", + `{"Calendar::Event":[`+allDay+`]}`, + `{"Calendar::Countdown":[`+eightDays+`]}`, + func(t *testing.T, form url.Values) { + if got := form.Get("countdown_interval_duration_value"); got != "8" { + t.Errorf("countdown value = %q, want the eight days the recording spans", got) + } + if got := form.Get("countdown_interval_duration_unit"); got != "86400" { + t.Errorf("countdown unit = %q, want days", got) + } + }) + _, err := runJSONCommand(t, handler, + "event", "edit", "4821", "--occurrence", "4821_2027-09-01", "--apply-to", "current", "--title", "Sarah's birthday (party)") + if err != nil { + t.Fatalf("execute occurrence edit: %v", err) + } +} + +// A repeat count of nothing is not "forever": zero and a negative count are refused +// before anything is read, on the future edit that would split the series and on a create. +func TestEventsRefuseARepeatCountOfNothing(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "future edit, zero", args: []string{"event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "future", "--repeat", "every_week", "--repeat-times=0"}}, + {name: "future edit, negative", args: []string{"event", "edit", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "future", "--repeat", "every_week", "--repeat-times=-1"}}, + {name: "add, zero", args: []string{"event", "add", "Standup", "--calendar", "9", "--repeat", "every_weekday", "--repeat-times=0"}}, + {name: "whole event, zero", args: []string{"event", "edit", "4821", "--repeat", "every_week", "--repeat-times", "0"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var requests atomic.Int32 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, oneCalendarJSON) + case r.Method == http.MethodGet: + _, _ = io.WriteString(w, `{"Calendar::Event":[`+occurrenceSeriesJSON+`]}`) + default: + t.Errorf("unexpected write = %s %s", r.Method, r.URL) + http.NotFound(w, r) + } + }) + _, err := runJSONCommand(t, handler, tt.args...) + var cliErr *apierr.Error + if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage || !strings.Contains(cliErr.Message, "is not a number of occurrences") { + t.Fatalf("error = %v, want the repeat-times refusal", err) + } + if strings.HasPrefix(tt.name, "future") && requests.Load() != 0 { + t.Errorf("requests = %d, want none before the flags are read", requests.Load()) + } + }) + } +} + +// The day and the week say which id an occurrence carries: the series' for one HEY draws +// from the series, its own for a day HEY has written out, with the series in parent_id. +func TestEventsPeriodHelpNamesTheSeriesID(t *testing.T) { + for _, command := range []*eventsPeriodCommand{newEventsDayCommand(), newEventsWeekCommand()} { + for _, want := range []string{"parent_id", "occurrence_id", "written out"} { + if !strings.Contains(command.cmd.Long, want) { + t.Errorf("%s help does not mention %q", command.cmd.Name(), want) + } + } + } +} + // HEY names a day by the UTC date of its start and gives it the series' wall-clock time in // the series' zone, so the wall-clock day can be the day either side of the one named. func TestOccurrenceInstants(t *testing.T) { @@ -940,38 +1017,47 @@ func TestOccurrenceInstants(t *testing.T) { } // HEY labels a countdown by trying months, then weeks, then days, taking a remainder of up -// to a day as a match — so a countdown exactly one day long is "0 months before", and only -// the recording's own span says what it is. +// to a whole day as a match — so eight days at midnight is "1 weeks before", 29 days is +// "4 weeks before" and one day is "0 months before". The recording's span is the countdown +// plus however far into its day the event starts, and that is what the length is read off; +// the label only settles days against HEY's month, which is not a whole number of them. func TestCountdownFromRecording(t *testing.T) { - span := func(hours int) (time.Time, time.Time) { + const month = time.Duration(hey.CountdownUnitMonths) * time.Second + span := func(length time.Duration) (time.Time, time.Time) { end := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) - return end.Add(-time.Duration(hours) * time.Hour), end + return end.Add(-length), end } tests := []struct { name string label string - hours int + length time.Duration value int unit hey.CountdownUnit invalid bool }{ - {name: "weeks", label: "3 weeks before", hours: 21 * 24, value: 3, unit: hey.CountdownUnitWeeks}, - {name: "days, plural", label: "1 days before", hours: 36, value: 1, unit: hey.CountdownUnitDays}, - {name: "day", label: "1 day before", hours: 36, value: 1, unit: hey.CountdownUnitDays}, - {name: "months", label: "6 months before", hours: 183 * 24, value: 6, unit: hey.CountdownUnitMonths}, - {name: "one day on an all-day event", label: "0 months before", hours: 24, value: 1, unit: hey.CountdownUnitDays}, - {name: "zero with an unexplained span", label: "0 months before", hours: 20, invalid: true}, + {name: "weeks at midnight", label: "3 weeks before", length: 21 * 24 * time.Hour, value: 3, unit: hey.CountdownUnitWeeks}, + {name: "weeks on a timed event", label: "3 weeks before", length: 21*24*time.Hour + 12*time.Hour, value: 3, unit: hey.CountdownUnitWeeks}, + {name: "days, plural", label: "1 days before", length: 36 * time.Hour, value: 1, unit: hey.CountdownUnitDays}, + {name: "day", label: "1 day before", length: 36 * time.Hour, value: 1, unit: hey.CountdownUnitDays}, + {name: "months", label: "6 months before", length: 6*month + 9*time.Hour, value: 6, unit: hey.CountdownUnitMonths}, + {name: "eight days labelled a week", label: "1 weeks before", length: 8 * 24 * time.Hour, value: 8, unit: hey.CountdownUnitDays}, + {name: "29 days labelled four weeks", label: "4 weeks before", length: 29 * 24 * time.Hour, value: 29, unit: hey.CountdownUnitDays}, + {name: "30 days at midnight", label: "30 days before", length: 30 * 24 * time.Hour, value: 30, unit: hey.CountdownUnitDays}, + {name: "one day on an all-day event", label: "0 months before", length: 24 * time.Hour, value: 1, unit: hey.CountdownUnitDays}, + {name: "a month the label settles", label: "1 months before", length: month + 3*time.Hour, value: 1, unit: hey.CountdownUnitMonths}, + {name: "no span, so the label", label: "2 weeks before", value: 2, unit: hey.CountdownUnitWeeks}, + {name: "zero with an unexplained span", label: "0 months before", length: 20 * time.Hour, invalid: true}, {name: "zero with no span", label: "0 months before", invalid: true}, - {name: "no countdown", label: "Countdown", hours: 24, invalid: true}, - {name: "empty", label: "", hours: 24, invalid: true}, - {name: "unknown unit", label: "3 fortnights before", hours: 42 * 24, invalid: true}, - {name: "words", label: "three weeks before", hours: 21 * 24, invalid: true}, + {name: "no countdown", label: "Countdown", length: 24 * time.Hour, invalid: true}, + {name: "empty", label: "", length: 24 * time.Hour, invalid: true}, + {name: "unknown unit", label: "3 fortnights before", length: 42 * 24 * time.Hour, invalid: true}, + {name: "words", label: "three weeks before", length: 21 * 24 * time.Hour, invalid: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { recording := generated.Recording{Type: recordingTypeCountdown, Label: tt.label} - if tt.hours > 0 { - recording.StartsAt, recording.EndsAt = span(tt.hours) + if tt.length > 0 { + recording.StartsAt, recording.EndsAt = span(tt.length) } countdown, err := countdownFromRecording(recording) if tt.invalid { @@ -990,10 +1076,12 @@ func TestCountdownFromRecording(t *testing.T) { } } -// A countdown labelled "0 months before" whose span does not say it is one day is not -// guessed at: the edit stops before writing, as with any label it cannot read. +// A countdown labelled "0 months before" whose span is under a day says nothing readable +// about its length, and is not guessed at: the edit stops before writing, as with any +// recording it cannot read. func TestEventsEditOccurrenceFailsClosedOnAZeroCountdown(t *testing.T) { - zero := strings.Replace(occurrenceCountdownJSON, "3 weeks before", "0 months before", 1) + zero := strings.NewReplacer("3 weeks before", "0 months before", "2026-08-11T00:00:00Z", "2026-08-31T16:00:00Z"). + Replace(occurrenceCountdownJSON) handler, writes := occurrenceServer(t, "2026-09-15", `{"Calendar::Event":[`+occurrenceSeriesJSON+`]}`, `{"Calendar::Countdown":[`+zero+`]}`, diff --git a/internal/cmd/events_period.go b/internal/cmd/events_period.go index 682e531d..d899e36a 100644 --- a/internal/cmd/events_period.go +++ b/internal/cmd/events_period.go @@ -58,8 +58,12 @@ event that falls on it, occurrences of a repeating series included, and nothing 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.`, +no --calendar to narrow it. 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 — after an edit of that day alone, or a reminder — carries an id of +its own, which those two act on for that day alone, and names its series in parent_id. +Either way the occurrence_id is what 'hey event edit --occurrence' takes, with the series +id (parent_id) before it.`, Example: ` hey event day hey event day 2026-09-02 hey event day --json`, @@ -90,8 +94,12 @@ func newEventsWeekCommand() *eventsPeriodCommand { 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.`, +no --calendar to narrow it. 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 — after an edit of that day alone, or a reminder — carries an id of +its own, which those two act on for that day alone, and names its series in parent_id. +Either way the occurrence_id is what 'hey event edit --occurrence' takes, with the series +id (parent_id) before it.`, Example: ` hey event week hey event week 2026-09-02 hey event week --json`, diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index c4ea701d..1c8fc7c8 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -697,8 +697,10 @@ 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, +carrying that day's own times and an `occurrence_id`. An occurrence drawn from the series +has the series' `id` (what `edit`/`delete` take for the whole series); a day HEY has +written out on its own has its own `id` (acting on that day alone) with the series in +`parent_id`. 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`,