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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,10 @@ hey event delete 4821

Without `--calendar`, `hey event list` reads every calendar and `hey event add` files on
the first one it can — the personal calendar is in the list HEY serves but refuses events.
A repeating event lists once as the series it is stored as, not once per day it falls on.
The list follows every page HEY serves. Within each calendar HEY orders recordings by
newest start time, not creation time, so use the ID returned by `hey event add` for a follow-up
edit or delete rather than choosing an event by its position in the list. A repeating event
lists once as the series it is stored as, not once per day it falls on.

An event with no `--start-time` is an all-day event, and a `--start-time` with no
`--end-time` runs for an hour. Clock times are read in `--time-zone`, which defaults to the
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ require (
charm.land/glamour/v2 v2.0.1
charm.land/lipgloss/v2 v2.0.6
github.com/basecamp/actioncable-go v0.0.0-20260824145920-822e6cf08655
github.com/basecamp/hey-sdk/go v0.24.0
github.com/basecamp/hey-sdk/go v0.25.0
github.com/charmbracelet/x/ansi v0.11.8
github.com/fsnotify/fsnotify v1.10.1
github.com/gofrs/flock v0.13.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/basecamp/actioncable-go v0.0.0-20260824145920-822e6cf08655 h1:zz0WUSEmjURj0T+soXuTtgX291nYouqa+UoyYY3Xxk8=
github.com/basecamp/actioncable-go v0.0.0-20260824145920-822e6cf08655/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs=
github.com/basecamp/hey-sdk/go v0.24.0 h1:yEKHXAcX2yGhiphe6NetBLlfxtjJoM8r8pat3N1lYxs=
github.com/basecamp/hey-sdk/go v0.24.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk=
github.com/basecamp/hey-sdk/go v0.25.0 h1:d1dHUXzMimExHeGqAp10/BsbK0j9b99m8D0WO3kbVkA=
github.com/basecamp/hey-sdk/go v0.25.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
Expand Down
96 changes: 96 additions & 0 deletions internal/cmd/calendar_commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,102 @@ func TestEventsListReadsEveryCalendar(t *testing.T) {
}
}

// Calendar recordings are geared-paginated newest start first. A recurring series that
// began long ago can sit behind a full page of newer one-off events even while it recurs in
// the requested window, so stopping at the first page makes the series disappear.
func TestEventsListFollowsEveryRecordingsPage(t *testing.T) {
var pages []string
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page := r.URL.Query().Get("page")
pages = append(pages, page)
w.Header().Set("Content-Type", "application/json")
switch page {
case "":
w.Header().Set("Link", `</calendars/7/recordings.json?page=older-starts>; rel="next"`)
_, _ = io.WriteString(w, `{"Calendar::Event":[{"id":1,"title":"Design review","starts_at":"2026-08-03T09:00:00Z"}]}`)
case "older-starts":
_, _ = io.WriteString(w, `{"Calendar::Event":[{"id":2,"title":"Weekly planning","recurring":true,"starts_at":"2025-01-06T09:00:00Z"}]}`)
default:
http.Error(w, "unexpected page", http.StatusBadRequest)
}
})

ids, err := runFormattedCommand(t, handler, []string{"--ids-only"},
"event", "list", "--calendar", "7", "--starts-on", "2026-08-01", "--ends-on", "2026-08-31")
if err != nil {
t.Fatalf("execute events list --ids-only: %v", err)
}
if got := strings.Join(pages, ","); got != ",older-starts" {
t.Errorf("pages = %q, want the first page followed by HEY's cursor", got)
}
if got := strings.Fields(ids); len(got) != 2 || got[0] != "1" || got[1] != "2" {
t.Errorf("ids = %q, want the one-off event and the recurring series from page two", ids)
}
}

// An empty page ends a geared-paginated list even when it carries another cursor. Following
// that cursor can make an empty last page cycle back into pages already read.
func TestEventsListStopsOnAnEmptyRecordingsPage(t *testing.T) {
var pages []string
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page := r.URL.Query().Get("page")
pages = append(pages, page)
w.Header().Set("Content-Type", "application/json")
switch page {
case "":
w.Header().Set("Link", `</calendars/7/recordings.json?page=empty-page>; rel="next"`)
_, _ = io.WriteString(w, `{"Calendar::Event":[{"id":1,"title":"Design review"}]}`)
case "empty-page":
w.Header().Set("Link", `</calendars/7/recordings.json?page=must-not-be-read>; rel="next"`)
_, _ = io.WriteString(w, `{}`)
default:
http.Error(w, "unexpected page", http.StatusBadRequest)
}
})

ids, err := runFormattedCommand(t, handler, []string{"--ids-only"},
"event", "list", "--calendar", "7", "--starts-on", "2026-08-01", "--ends-on", "2026-08-31")
if err != nil {
t.Fatalf("execute events list --ids-only: %v", err)
}
if got := strings.Join(pages, ","); got != ",empty-page" {
t.Errorf("pages = %q, want the first and empty pages only", got)
}
if got := strings.Fields(ids); len(got) != 1 || got[0] != "1" {
t.Errorf("ids = %q, want the event from the first page", ids)
}
}

// A server repeating a geared cursor cannot make the CLI read forever. The second page is
// accepted once, then its repeated cursor is refused before another request is made.
func TestEventsListRefusesARepeatedRecordingsCursor(t *testing.T) {
var pages []string
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page := r.URL.Query().Get("page")
pages = append(pages, page)
w.Header().Set("Content-Type", "application/json")
switch page {
case "":
w.Header().Set("Link", `</calendars/7/recordings.json?page=repeated>; rel="next"`)
_, _ = io.WriteString(w, `{"Calendar::Event":[{"id":1,"title":"Design review"}]}`)
case "repeated":
w.Header().Set("Link", `</calendars/7/recordings.json?page=repeated>; rel="next"`)
_, _ = io.WriteString(w, `{"Calendar::Event":[{"id":2,"title":"Weekly planning"}]}`)
default:
http.Error(w, "unexpected page", http.StatusBadRequest)
}
})

_, err := runFormattedCommand(t, handler, []string{"--ids-only"},
"event", "list", "--calendar", "7", "--starts-on", "2026-08-01", "--ends-on", "2026-08-31")
if err == nil || !strings.Contains(err.Error(), "calendar recordings pagination repeated a page") {
t.Fatalf("error = %v, want the repeated-cursor refusal", err)
}
if got := strings.Join(pages, ","); got != ",repeated" {
t.Errorf("pages = %q, want no request after the cursor repeated", got)
}
}

func TestEventsListDefaultsEndDateFromStart(t *testing.T) {
response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("ends_on"); got != "2026-03-03" {
Expand Down
52 changes: 44 additions & 8 deletions internal/cmd/recording_filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,26 +106,62 @@ func (f *recordingFilter) resolve(ctx context.Context) (recordingWindow, error)
}

// read lists the recordings of one type over the window, calendar by calendar in the order
// HEY listed the calendars.
// HEY listed the calendars, following every page of each calendar.
//
// A recurring event is one recording here rather than one per day it falls on: a calendar
// lists what it holds, and only the day, week and year reads expand a recurrence into the
// occurrences inside them.
// 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) {
recordings := []generated.Recording{}
for _, calendarID := range w.calendars {
resp, err := sdk.Calendars().GetRecordings(ctx, calendarID, &generated.GetCalendarRecordingsParams{
StartsOn: &w.startsOn,
EndsOn: &w.endsOn,
})
calendarRecordings, err := w.readCalendar(ctx, calendarID, recType)
if err != nil {
return nil, apierr.FromSDK(err)
return nil, err
}
recordings = append(recordings, filterRecordingsByType(resp, recType)...)
recordings = append(recordings, calendarRecordings...)
}
return recordings, nil
}

func (w recordingWindow) readCalendar(ctx context.Context, calendarID int64, recType string) ([]generated.Recording, error) {
params := &generated.GetCalendarRecordingsParams{StartsOn: &w.startsOn, EndsOn: &w.endsOn}
recordings := []generated.Recording{}
seenPages := map[string]bool{}

for {
page, err := sdk.Calendars().GetRecordingsPage(ctx, calendarID, params)
if err != nil {
return nil, apierr.FromSDK(err)
}
if page == nil {
return recordings, nil
}

recordings = append(recordings, filterRecordingsByType(page.Recordings, recType)...)
if page.NextPage == "" || calendarRecordingsEmpty(page.Recordings) {
return recordings, nil
}
if seenPages[page.NextPage] {
return nil, apierr.ErrAPI(0, "calendar recordings pagination repeated a page")
Comment thread
robzolkos marked this conversation as resolved.
}
seenPages[page.NextPage] = true
params.Page = &page.NextPage
}
}

func calendarRecordingsEmpty(recordings *generated.CalendarRecordingsResponse) bool {
if recordings == nil {
return true
}
for _, grouped := range *recordings {
if len(grouped) > 0 {
return false
}
}
return true
}

// describe names the window for a summary line.
func (w recordingWindow) describe() string {
return fmt.Sprintf("%s to %s", w.startsOn, w.endsOn)
Expand Down
2 changes: 1 addition & 1 deletion nix/package.nix
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ buildGoModule.override { inherit go; } (finalAttrs: {

# To update: run `make update-nix-hash` (Docker). It rewrites this quoted
# value in place, so keep it a string literal rather than lib.fakeHash.
vendorHash = "sha256-aQ5nmKDTTEVb2lnVlhMfoVlm4VdsR2hm2YBbvq7Ks1A=";
vendorHash = "sha256-jrvzWPmLQBHEakJvp5crisp1YHVubpTSOST9qQaFung=";

subPackages = [ "cmd/hey" ];

Expand Down
Loading