diff --git a/docs/cli.md b/docs/cli.md index 1bd714132..bbcf30277 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -432,7 +432,7 @@ asking. ### Events ```bash -hey event list # every calendar, today through the next 30 days +hey event list # every calendar, today and the 29 days after it hey event list --calendar 123 --starts-on 2026-01-01 --ends-on 2026-01-31 hey event list --count # how many events in the window @@ -460,6 +460,17 @@ newest start time, not creation time, so use the ID returned by `hey event add` edit or delete rather than choosing an event by its position in the list. A repeating event lists once as the series it is stored as, plus a row for each day HEY has written out on its own. +A date given to `hey event list`, `hey event day` and `hey event week` is a day in your HEY +account's time zone. If the account has none, it is a day in the machine's zone, and the +command says so once on stderr. HEY reads a date as a UTC day, so the CLI reads the days +either side too and keeps the events that overlap the days you named. An event at 9:15 PM in +New York is on its own date, not on the next one. `hey event list` includes `--ends-on`, +lists an all-day event on the dates it names, and lists a repeating series as the one row HEY +stores, unless the series starts after the last day. + +`hey event edit ` looks for the event from the day before the date to the day +after it, so it finds an evening or early-morning event by the date it shows 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 and an `occurrence_id`. A virtual occurrence carries the series in `id` diff --git a/docs/tui.md b/docs/tui.md index 0a5685f31..650985365 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -214,6 +214,10 @@ The private note is shown formatted and edited as Markdown, the way `hey contact Press Shift+C to open Calendar, then `c` to manage time track categories. Create a category with `n`, rename the selected category with Enter or `r`, and press `x` twice to delete it. Time tracks in a deleted category become uncategorized. +The day and week views show days of this machine's local time zone. A timed event belongs to +the local day it overlaps, and the app reads the day either side of the span to find it. An +all-day event stays on its own dates. + A new event's times are written in your HEY account's time zone, the one HEY's web app and `hey event add` use: the Starts and Ends rows name it, and the event is saved in it. The Calendar reads the zone each time it opens; if the account has none or it cannot be read, diff --git a/internal/calendarday/calendarday.go b/internal/calendarday/calendarday.go new file mode 100644 index 000000000..19d06498a --- /dev/null +++ b/internal/calendarday/calendarday.go @@ -0,0 +1,197 @@ +// Package calendarday finds the events of the reader's local days. HEY reads a date as a +// UTC day, so a timed event can sit on the day before or after the one the reader sees. +// The package finds those events from the read of the days and from its neighbour days. +package calendarday + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/basecamp/hey-sdk/go/pkg/generated" +) + +const ( + // EventType is the key HEY files a calendar's events under in a period's recordings. + EventType = "Calendar::Event" + + dateLayout = "2006-01-02" +) + +// Span is the run of UTC days a period covers, from First to Last inclusive. It is how a +// read of HEY is told apart from the local days the reader asked for. +type Span struct { + First time.Time + Last time.Time +} + +// DayReader reads one UTC day by its date. The CLI and the TUI each hand the package their +// own, so it never sees the SDK client or how a calendar is chosen. +type DayReader func(ctx context.Context, date string) (*generated.CalendarPeriod, error) + +// ReadNeighbours reads the day before and the day after a period, at the same time. An +// event of a local day can sit on either UTC day, and a failed read is an error because a +// day with a missing neighbour would silently lose events. +func ReadNeighbours(ctx context.Context, main *generated.CalendarPeriod, readDay DayReader) ([]*generated.CalendarPeriod, error) { + if main == nil || main.StartsAt.IsZero() { + return nil, nil + } + before, after := SpanOf(main).Neighbours() + dates := []string{before, after} + periods := make([]*generated.CalendarPeriod, len(dates)) + failures := make([]error, len(dates)) + + var group sync.WaitGroup + for i, date := range dates { + group.Add(1) + go func() { + defer group.Done() + periods[i], failures[i] = readDay(ctx, date) + }() + } + group.Wait() + + for _, err := range failures { + if err != nil { + return nil, err + } + } + return periods, nil +} + +// WithLocalEvents is a copy of a period whose events are the ones on its local days. The +// period read from HEY stays as it was, because other readers share it. +func WithLocalEvents(main *generated.CalendarPeriod, neighbours []*generated.CalendarPeriod, loc *time.Location) *generated.CalendarPeriod { + if main == nil || main.StartsAt.IsZero() { + return main + } + local := *main + local.Recordings = make(generated.CalendarRecordingsResponse, len(main.Recordings)) + for kind, recordings := range main.Recordings { + local.Recordings[kind] = recordings + } + local.Recordings[EventType] = LocalEvents(main, neighbours, loc) + return &local +} + +// LocalEvents are the events that overlap the local days of a period, once each. An +// all-day event is a date rather than an instant, so only the period's own read decides it. +// A timed event that spans two local days is on both. +func LocalEvents(main *generated.CalendarPeriod, neighbours []*generated.CalendarPeriod, loc *time.Location) []generated.Recording { + if main == nil { + return nil + } + from, to := SpanOf(main).Local(loc) + events := []generated.Recording{} + seen := map[string]bool{} + keep := func(event generated.Recording) { + key := identity(event) + if seen[key] { + return + } + seen[key] = true + events = append(events, event) + } + + for _, event := range main.Recordings[EventType] { + if event.AllDay || Overlaps(event, from, to) { + keep(event) + } + } + for _, neighbour := range neighbours { + if neighbour == nil { + continue + } + for _, event := range neighbour.Recordings[EventType] { + if !event.AllDay && Overlaps(event, from, to) { + keep(event) + } + } + } + return events +} + +// InDays reports whether a recording belongs to the local days first through last. It +// answers for a list read, which asks for a run of days and has no period to answer from. +// HEY's series rows carry no end date, so a repeating row is kept unless it starts later. An +// all-day series is a date, so it is compared by its date, and a timed series by its instant. +func InDays(r generated.Recording, first, last time.Time, loc *time.Location) bool { + if r.StartsAt.IsZero() { + return true + } + if r.Recurring { + if r.AllDay { + return !utcDate(r.StartsAt).After(last) + } + return r.StartsAt.Before(civilMidnight(last.AddDate(0, 0, 1), loc)) + } + if r.AllDay { + startsOn := utcDate(r.StartsAt) + endsOn := startsOn + if r.EndsAt.After(r.StartsAt) { + endsOn = utcDate(r.EndsAt) + } + return !startsOn.After(last) && !endsOn.Before(first) + } + return Overlaps(r, civilMidnight(first, loc), civilMidnight(last.AddDate(0, 0, 1), loc)) +} + +// SpanOf is the UTC days a period covers. HEY ends a period at midnight, which belongs to +// the day after, so that midnight does not count as a day of its own. +func SpanOf(period *generated.CalendarPeriod) Span { + first := utcDate(period.StartsAt) + last := utcDate(period.EndsAt) + endsAtMidnight := period.EndsAt.UTC().Equal(last) + if endsAtMidnight && period.EndsAt.After(period.StartsAt) { + last = last.AddDate(0, 0, -1) + } + if last.Before(first) { + last = first + } + return Span{First: first, Last: last} +} + +// Overlaps reports whether a recording is inside [from, to). An instant belongs to the day +// it starts on, and a run belongs to every day it touches, but a run that ends at the +// start of a day is not on that day. +func Overlaps(r generated.Recording, from, to time.Time) bool { + if r.EndsAt.IsZero() || !r.EndsAt.After(r.StartsAt) { + return !r.StartsAt.Before(from) && r.StartsAt.Before(to) + } + return r.StartsAt.Before(to) && r.EndsAt.After(from) +} + +// utcDate is the UTC day an instant falls on, as a midnight in UTC. HEY files an event under +// that day, whatever zone the reader keeps. +func utcDate(at time.Time) time.Time { + at = at.UTC() + return time.Date(at.Year(), at.Month(), at.Day(), 0, 0, 0, 0, time.UTC) +} + +// civilMidnight is the start of a date in the reader's zone. The date is read off day as it +// is, so a UTC day maps to the same calendar date there. +func civilMidnight(day time.Time, loc *time.Location) time.Time { + return time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc) +} + +// identity names an event so that the same one read under two days counts once. A recording +// with no id has none to go by, so its type, start and title stand in. +func identity(event generated.Recording) string { + if event.Id != 0 || event.OccurrenceId != "" { + return fmt.Sprintf("%d|%s", event.Id, event.OccurrenceId) + } + return fmt.Sprintf("%s|%d|%s", event.Type, event.StartsAt.UnixNano(), event.Title) +} + +// Neighbours are the dates of the UTC days just outside the span. HEY can file an event of +// the span's local days under either of them. +func (s Span) Neighbours() (before, after string) { + return s.First.AddDate(0, 0, -1).Format(dateLayout), s.Last.AddDate(0, 0, 1).Format(dateLayout) +} + +// Local is the span as a range of instants in the reader's zone, from the start of its +// first day up to the start of the day after its last. +func (s Span) Local(loc *time.Location) (from, to time.Time) { + return civilMidnight(s.First, loc), civilMidnight(s.Last.AddDate(0, 0, 1), loc) +} diff --git a/internal/calendarday/calendarday_test.go b/internal/calendarday/calendarday_test.go new file mode 100644 index 000000000..9adb56280 --- /dev/null +++ b/internal/calendarday/calendarday_test.go @@ -0,0 +1,158 @@ +package calendarday + +import ( + "slices" + "testing" + "time" + + "github.com/basecamp/hey-sdk/go/pkg/generated" +) + +func instant(t *testing.T, value string) time.Time { + t.Helper() + at, err := time.Parse(time.RFC3339, value) + if err != nil { + t.Fatal(err) + } + return at +} + +func period(t *testing.T, first, last string, events ...generated.Recording) *generated.CalendarPeriod { + t.Helper() + return &generated.CalendarPeriod{ + StartsAt: instant(t, first+"T00:00:00Z"), + EndsAt: instant(t, last+"T23:59:59Z"), + Recordings: generated.CalendarRecordingsResponse{EventType: events}, + } +} + +func TestSpanOf(t *testing.T) { + newYork, _ := time.LoadLocation("America/New_York") + cases := []struct { + name, starts, ends, last, before, after string + hours int + }{ + {"week", "2026-09-14T00:00:00Z", "2026-09-20T23:59:59Z", "2026-09-20", "2026-09-13", "2026-09-21", 7 * 24}, + {"end at midnight", "2026-09-14T00:00:00Z", "2026-09-21T00:00:00Z", "2026-09-20", "2026-09-13", "2026-09-21", 7 * 24}, + {"clock change", "2026-11-01T00:00:00Z", "2026-11-01T23:59:59Z", "2026-11-01", "2026-10-31", "2026-11-02", 25}, + } + for _, tc := range cases { + span := SpanOf(&generated.CalendarPeriod{StartsAt: instant(t, tc.starts), EndsAt: instant(t, tc.ends)}) + before, after := span.Neighbours() + from, to := span.Local(newYork) + if span.Last.Format(dateLayout) != tc.last || before != tc.before || after != tc.after || int(to.Sub(from).Hours()) != tc.hours { + t.Errorf("%s: last %s, neighbours %s %s, length %s", tc.name, span.Last.Format(dateLayout), before, after, to.Sub(from)) + } + } +} + +func TestLocalEvents(t *testing.T) { + event := func(id int64, title, starts, ends string) generated.Recording { + r := generated.Recording{Id: id, Title: title, StartsAt: instant(t, starts)} + if ends != "" { + r.EndsAt = instant(t, ends) + } + return r + } + breakfast := event(1, "Breakfast", "2026-09-20T13:00:00Z", "2026-09-20T14:00:00Z") + dinner := event(2, "Dinner", "2026-09-21T00:50:00Z", "2026-09-21T02:40:00Z") + concert := event(3, "Concert", "2026-09-18T22:00:00Z", "2026-09-19T01:30:00Z") + flight := event(4, "Flight", "2026-09-20T23:00:00Z", "2026-09-21T05:00:00Z") + lateShow := event(5, "Late show", "2026-11-02T04:30:00Z", "2026-11-02T04:45:00Z") + earlyStart := event(6, "Early start", "2026-11-02T05:00:00Z", "2026-11-02T06:00:00Z") + closing := event(10, "Closing", "2026-09-19T02:00:00Z", "2026-09-19T04:00:00Z") + midnight := event(7, "Midnight", "2026-09-21T00:00:00Z", "") + holiday := event(8, "Holiday", "2026-09-20T00:00:00Z", "2026-09-20T00:00:00Z") + holiday.AllDay = true + nextHoliday := event(9, "Next holiday", "2026-09-21T00:00:00Z", "2026-09-21T00:00:00Z") + nextHoliday.AllDay = true + standup := generated.Recording{OccurrenceId: "7_2026-09-21", Title: "Standup", StartsAt: instant(t, "2026-09-21T14:00:00Z")} + + cases := []struct { + name, zone, date string + main, before, after []generated.Recording + want []string + }{ + {"evening in New York", "America/New_York", "2026-09-20", []generated.Recording{breakfast}, nil, []generated.Recording{dinner}, []string{"Breakfast", "Dinner"}}, + {"next local day", "America/New_York", "2026-09-21", []generated.Recording{dinner}, nil, nil, nil}, + {"ended before the day", "America/New_York", "2026-09-19", []generated.Recording{concert}, nil, nil, nil}, + {"ended at local midnight", "America/New_York", "2026-09-19", []generated.Recording{closing}, nil, nil, nil}, + {"own local day", "America/New_York", "2026-09-18", []generated.Recording{concert}, nil, nil, []string{"Concert"}}, + {"east of UTC", "Asia/Tokyo", "2026-09-21", []generated.Recording{dinner}, nil, nil, []string{"Dinner"}}, + {"east of UTC, day before", "Asia/Tokyo", "2026-09-20", nil, nil, []generated.Recording{dinner}, nil}, + {"only from the day before", "UTC", "2026-09-19", nil, []generated.Recording{concert}, nil, []string{"Concert"}}, + {"clock change", "America/New_York", "2026-11-01", nil, nil, []generated.Recording{lateShow, earlyStart}, []string{"Late show"}}, + {"all-day from main only", "UTC", "2026-09-20", []generated.Recording{holiday}, nil, []generated.Recording{nextHoliday}, []string{"Holiday"}}, + {"same id twice", "UTC", "2026-09-21", []generated.Recording{flight}, []generated.Recording{flight}, nil, []string{"Flight"}}, + {"same occurrence twice", "UTC", "2026-09-21", []generated.Recording{standup}, []generated.Recording{standup}, nil, []string{"Standup"}}, + {"instant at the start", "UTC", "2026-09-21", nil, nil, []generated.Recording{midnight}, []string{"Midnight"}}, + {"instant at the end", "UTC", "2026-09-20", nil, nil, []generated.Recording{midnight}, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + loc, _ := time.LoadLocation(tc.zone) + main := period(t, tc.date, tc.date, tc.main...) + neighbours := []*generated.CalendarPeriod{period(t, "2026-09-01", "2026-09-01", tc.before...), period(t, "2026-09-02", "2026-09-02", tc.after...)} + var got []string + for _, e := range LocalEvents(main, neighbours, loc) { + got = append(got, e.Title) + } + slices.Sort(got) + if !slices.Equal(got, tc.want) { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} + +func TestInDays(t *testing.T) { + day := func(date string) time.Time { return instant(t, date+"T00:00:00Z") } + newYork, _ := time.LoadLocation("America/New_York") + evening := generated.Recording{StartsAt: instant(t, "2026-09-21T00:50:00Z"), EndsAt: instant(t, "2026-09-21T02:40:00Z")} + cases := []struct { + name string + event generated.Recording + first string + last string + want bool + }{ + {"evening on its local day", evening, "2026-09-20", "2026-09-20", true}, + {"evening not on the next day", evening, "2026-09-21", "2026-09-21", false}, + {"series from a year before", generated.Recording{Recurring: true, StartsAt: instant(t, "2025-09-15T14:00:00Z")}, "2026-09-20", "2026-09-20", true}, + {"series from the day after", generated.Recording{Recurring: true, StartsAt: instant(t, "2026-09-21T14:00:00Z")}, "2026-09-20", "2026-09-20", false}, + {"series from the evening of the last day", generated.Recording{Recurring: true, StartsAt: instant(t, "2026-09-21T02:00:00Z")}, "2026-09-20", "2026-09-20", true}, + {"no start", generated.Recording{}, "2026-09-20", "2026-09-20", true}, + {"all-day on its date", generated.Recording{AllDay: true, StartsAt: day("2026-09-20"), EndsAt: day("2026-09-20")}, "2026-09-20", "2026-09-20", true}, + {"all-day outside", generated.Recording{AllDay: true, StartsAt: day("2026-09-20")}, "2026-09-21", "2026-09-22", false}, + {"multi-day all-day", generated.Recording{AllDay: true, StartsAt: day("2026-09-18"), EndsAt: day("2026-09-22")}, "2026-09-20", "2026-09-20", true}, + } + for _, tc := range cases { + if got := InDays(tc.event, day(tc.first), day(tc.last), newYork); got != tc.want { + t.Errorf("%s: InDays = %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestInDaysComparesAnAllDaySeriesByItsDate(t *testing.T) { + newYork, _ := time.LoadLocation("America/New_York") + tokyo, _ := time.LoadLocation("Asia/Tokyo") + cases := []struct { + name string + loc *time.Location + startsOn string + last string + want bool + }{ + {"New York, series from the day after", newYork, "2026-09-21", "2026-09-20", false}, + {"New York, series from the last day", newYork, "2026-09-20", "2026-09-20", true}, + {"Tokyo, series from the day after", tokyo, "2026-09-21", "2026-09-20", false}, + {"Tokyo, series from the last day", tokyo, "2026-09-20", "2026-09-20", true}, + } + for _, tc := range cases { + event := generated.Recording{Recurring: true, AllDay: true, StartsAt: instant(t, tc.startsOn+"T00:00:00Z")} + last := instant(t, tc.last+"T00:00:00Z") + if got := InDays(event, last, last, tc.loc); got != tc.want { + t.Errorf("%s: InDays = %v, want %v", tc.name, got, tc.want) + } + } +} diff --git a/internal/cmd/account_zone.go b/internal/cmd/account_zone.go index 1346b74bf..6e3cec5fa 100644 --- a/internal/cmd/account_zone.go +++ b/internal/cmd/account_zone.go @@ -25,9 +25,10 @@ const todayUnknown = "cannot tell which day today is" // or a today to name. A specific --account is validated by a separate identity read before // the command runs. type accountZone struct { - read bool - name string - err error + read bool + name string + err error + fellBack bool } // timeZone is the zone HEY's web app reads typed times and today in: the one the identity @@ -85,6 +86,7 @@ func (a *accountZone) todayToRead(ctx context.Context, stderr io.Writer, hint st _, loc, err := a.location(ctx) if unknown, ok := errors.AsType[*unknownAccountZoneError](err); ok { today := calendarDay(clockNow()) + a.fellBack = true fmt.Fprintf(stderr, "Notice: %s, so today is %s by this machine's clock\n", unknown.reason, today.Format(dateLayout)) return today, nil } @@ -94,6 +96,24 @@ func (a *accountZone) todayToRead(ctx context.Context, stderr io.Writer, hint st return calendarDay(clockNow().In(loc)), nil } +// locationToRead is the zone whose days a command reads. An account with no usable zone takes +// this machine's, as todayToRead does, and says so once on stderr. A read of the identity that +// fails is refused, because a guessed zone would list the wrong events. +func (a *accountZone) locationToRead(ctx context.Context, stderr io.Writer) (*time.Location, error) { + _, loc, err := a.location(ctx) + if unknown, ok := errors.AsType[*unknownAccountZoneError](err); ok { + if !a.fellBack { + a.fellBack = true + fmt.Fprintf(stderr, "Notice: %s, so days are this machine's days\n", unknown.reason) + } + return time.Local, nil + } + if err != nil { + return nil, accountZoneRefusal(err, "cannot tell which events fall on the days asked for", "retry the command") + } + return loc, nil +} + // calendarDay is the day an instant falls on where it was read, as a date with no zone left // in it, so that adding days to it never meets a clock change. func calendarDay(at time.Time) time.Time { diff --git a/internal/cmd/account_zone_test.go b/internal/cmd/account_zone_test.go index 6eda578cf..1688bdd23 100644 --- a/internal/cmd/account_zone_test.go +++ b/internal/cmd/account_zone_test.go @@ -121,6 +121,8 @@ type todayCommand struct { args []string sends func(day string) string write bool + + readsZoneForDays bool } var todayCommands = []todayCommand{ @@ -128,8 +130,8 @@ var todayCommands = []todayCommand{ {name: "event week", args: []string{"event", "week"}, sends: func(day string) string { return "GET /calendar/weeks/" + day }}, {name: "event list", args: []string{"event", "list"}, sends: func(day string) string { start, _ := time.Parse(dateLayout, day) - return "GET /calendars/9/recordings starts_on=" + day + " ends_on=" + start.AddDate(0, 0, 30).Format(dateLayout) - }}, + return "GET /calendars/9/recordings starts_on=" + start.AddDate(0, 0, -1).Format(dateLayout) + " ends_on=" + start.AddDate(0, 0, 31).Format(dateLayout) + }, readsZoneForDays: true}, {name: "habit list", args: []string{"habit", "list"}, sends: func(day string) string { return "GET /calendar/weeks/" + day }}, {name: "journal read", args: []string{"journal", "read"}, sends: func(day string) string { return "GET /calendar/days/" + day + "/journal_entry" }}, {name: "habit complete", args: []string{"habit", "complete", "42"}, write: true, sends: func(day string) string { return "POST /calendar/days/" + day + "/habits/42/completions" }}, @@ -175,9 +177,13 @@ func TestTodayIsTheAccountsToday(t *testing.T) { } } -// A day that is named is the day sent, and the account is not asked for its zone. +// A day that is named is the day sent, and the account is not asked for its zone, bar the +// list, which cuts events to local days. func TestANamedDayReadsNoAccountZone(t *testing.T) { for _, command := range todayCommands { + if command.readsZoneForDays { + continue + } t.Run(command.name, func(t *testing.T) { atInstantOn(t, "2026-10-15T02:00:00Z", "UTC") handler, requests := todayServer(t, todayFixture{identityStatus: http.StatusInternalServerError}) diff --git a/internal/cmd/calendar_commands_test.go b/internal/cmd/calendar_commands_test.go index 0eb9324a8..a9791d6cb 100644 --- a/internal/cmd/calendar_commands_test.go +++ b/internal/cmd/calendar_commands_test.go @@ -105,21 +105,21 @@ func TestCalendarsCommandAPIError(t *testing.T) { } func TestEventsListCommand(t *testing.T) { - response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response, err := runJSONCommand(t, withLocalDays(nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/calendars/7/recordings.json" { t.Errorf("request = %s %s", r.Method, r.URL.Path) http.NotFound(w, r) return } - if got := r.URL.Query().Get("starts_on"); got != "2026-08-01" { - t.Errorf("starts_on = %q", got) + if got := r.URL.Query().Get("starts_on"); got != "2026-07-31" { + t.Errorf("starts_on = %q, want the day before so an evening in a zone west of UTC is inside", got) } - if got := r.URL.Query().Get("ends_on"); got != "2026-08-31" { - t.Errorf("ends_on = %q", got) + if got := r.URL.Query().Get("ends_on"); got != "2026-09-02" { + t.Errorf("ends_on = %q, want two days after so the last day's evening is inside", got) } w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":1,"title":"Planning","starts_at":"2026-08-02T09:00:00Z"},{"id":2,"title":"Review","starts_at":"2026-08-03T09:00:00Z"}],"Calendar::Todo":[{"id":3,"title":"Send notes"}]}`) - }), "event", "list", "--calendar", "7", "--starts-on", "2026-08-01", "--ends-on", "2026-08-31", "--limit", "1") + })), "event", "list", "--calendar", "7", "--starts-on", "2026-08-01", "--ends-on", "2026-08-31", "--limit", "1") if err != nil { t.Fatalf("execute events list: %v", err) } @@ -176,7 +176,7 @@ func TestEventsListReadsEveryCalendar(t *testing.T) { // 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) { + handler := withLocalDays(nil, 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") @@ -189,7 +189,7 @@ func TestEventsListFollowsEveryRecordingsPage(t *testing.T) { 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") @@ -208,7 +208,7 @@ func TestEventsListFollowsEveryRecordingsPage(t *testing.T) { // 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) { + handler := withLocalDays(nil, 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") @@ -222,7 +222,7 @@ func TestEventsListStopsOnAnEmptyRecordingsPage(t *testing.T) { 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") @@ -241,7 +241,7 @@ func TestEventsListStopsOnAnEmptyRecordingsPage(t *testing.T) { // 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) { + handler := withLocalDays(nil, 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") @@ -255,7 +255,7 @@ func TestEventsListRefusesARepeatedRecordingsCursor(t *testing.T) { 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") @@ -268,13 +268,13 @@ func TestEventsListRefusesARepeatedRecordingsCursor(t *testing.T) { } 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" { - t.Errorf("ends_on = %q, want 30 days after 2026-02-01", got) + response, err := runJSONCommand(t, withLocalDays(nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("ends_on"); got != "2026-03-04" { + t.Errorf("ends_on = %q, want the 30th day from 2026-02-01, and two days more for the zone", got) } w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{}`) - }), "event", "list", "--calendar", "7", "--starts-on", "2026-02-01") + })), "event", "list", "--calendar", "7", "--starts-on", "2026-02-01") if err != nil { t.Fatalf("execute events list: %v", err) } diff --git a/internal/cmd/events.go b/internal/cmd/events.go index df4365162..790107f20 100644 --- a/internal/cmd/events.go +++ b/internal/cmd/events.go @@ -15,6 +15,7 @@ import ( "github.com/basecamp/hey-sdk/go/pkg/hey" "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/calendarday" "github.com/basecamp/hey-cli/internal/output" "github.com/basecamp/hey-cli/internal/terminal" "github.com/basecamp/hey-cli/internal/timezone" @@ -69,7 +70,12 @@ series expanded into the days they fall on — read 'hey event day' or 'hey even Without --starts-on the window is the 30 days from today, today in your HEY account's time zone, whatever this machine's is. An account with no time zone set reads this machine's -today, and says so on stderr.`, +today, and says so on stderr. + +--starts-on and --ends-on name days in that same zone, and --ends-on is included. An +event is listed when it overlaps those days, so an evening event is on its own day and not +on the next UTC day. An all-day event is listed on the dates it names. A repeating series +is listed as the row HEY stores, unless the series starts after the last day.`, Example: ` hey event list hey event list --starts-on 2026-01-01 --ends-on 2026-01-31 hey event list --calendar 123 --limit 5 --json`, @@ -100,7 +106,7 @@ func (c *eventsListCommand) run(cmd *cobra.Command, args []string) error { return err } - events, err := window.read(ctx, recordingTypeEvent) + events, err := readEventsOfLocalDays(ctx, cmd, &account, window) if err != nil { return err } @@ -114,6 +120,40 @@ func (c *eventsListCommand) run(cmd *cobra.Command, args []string) error { return writeEventRows(cmd, eventRows(events), window.describe(), notice) } +// readEventsOfLocalDays reads the events that overlap the window's days in the account's zone. +// HEY reads a date as a UTC day, so it asks for a day more on each side and keeps what falls +// on the days named. +func readEventsOfLocalDays(ctx context.Context, cmd *cobra.Command, account *accountZone, window recordingWindow) ([]generated.Recording, error) { + loc, err := account.locationToRead(ctx, cmd.ErrOrStderr()) + if err != nil { + return nil, err + } + first, err := parseDateArg("starts-on date", window.startsOn) + if err != nil { + return nil, err + } + last, err := parseDateArg("ends-on date", window.endsOn) + if err != nil { + return nil, err + } + + wide := window + wide.startsOn = first.AddDate(0, 0, -1).Format(dateLayout) + wide.endsOn = last.AddDate(0, 0, 2).Format(dateLayout) + read, err := wide.read(ctx, recordingTypeEvent) + if err != nil { + return nil, err + } + + events := make([]generated.Recording, 0, len(read)) + for _, event := range read { + if calendarday.InDays(event, first, last, loc) { + events = append(events, event) + } + } + return events, nil +} + // eventBoundary writes one end of an event: a day for an all-day event, a day and a clock // time for a timed one. An all-day event has no time of day, and printing it midnight reads // as an event that starts at midnight. HEY's JSON is always UTC, so a timed boundary @@ -275,8 +315,9 @@ one, or the later where neither is; a time you keep at the other, or with second be sent, so the edit refuses until you retype it. All of this holds for an --occurrence edit too. The event is found by reading every calendar 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. +covers the pages HEY answers with. Give the day it starts as [date] to look from the day +before it to the day after it, so an evening or early-morning event is found by the date it +shows on. --calendar moves the event to that calendar, which has to be one you own or share: not your personal calendar and not a subscription. An event you cannot edit, such as an @@ -556,10 +597,10 @@ func movedReadBackError(id, calendarID int64, err error) error { // --calendar is not one of them: it is where the event is moving to, which is the one // calendar it is not on yet. // -// The day is read as the window [day, day+1): HEY's recordings window is a pair of -// instants, and the degenerate same-day window — midnight to the same midnight — can -// 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. +// The day is read as the window [day-1, day+2): HEY's recordings window is a pair of +// instants, and the degenerate same-day window can 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) { window, err := eventSearchWindow(ctx, on) if err != nil { @@ -588,14 +629,15 @@ func findEventIn(ctx context.Context, window recordingWindow, id int64) (generat } // eventSearchWindow is where an event given by id is looked for: the day given, read as -// [day, day+1), or a year either side of today, over every calendar. +// [day-1, day+2), or a year either side of today, over every calendar. func eventSearchWindow(ctx context.Context, on string) (recordingWindow, error) { - endsOn := on + startsOn, endsOn := on, on if day, err := time.Parse(dateLayout, on); err == nil { - endsOn = day.AddDate(0, 0, 1).Format(dateLayout) + startsOn = day.AddDate(0, 0, -1).Format(dateLayout) + endsOn = day.AddDate(0, 0, 2).Format(dateLayout) } filter := recordingFilter{ - startsOn: on, + startsOn: startsOn, endsOn: endsOn, defaultWindow: eventSearchYear, defaultCalendars: allCalendarIDs, diff --git a/internal/cmd/events_local_day_test.go b/internal/cmd/events_local_day_test.go new file mode 100644 index 000000000..1e65b7f80 --- /dev/null +++ b/internal/cmd/events_local_day_test.go @@ -0,0 +1,168 @@ +package cmd + +import ( + "io" + "net/http" + "strings" + "sync" + "testing" + "time" +) + +const ( + localDayDinner = `{"id":4821,"title":"Late dinner with Priya Patel","starts_at":"2026-09-21T00:50:00Z","ends_at":"2026-09-21T02:40:00Z","type":"Calendar::Event"}` + localDayConcert = `{"id":4822,"title":"Evening concert","starts_at":"2026-09-18T22:00:00Z","ends_at":"2026-09-19T01:30:00Z","type":"Calendar::Event"}` + localDayBreakfast = `{"id":4824,"title":"Breakfast with Maria Lopez","starts_at":"2026-09-20T13:00:00Z","ends_at":"2026-09-20T14:00:00Z","type":"Calendar::Event"}` + localDayCountdown = `{"id":4840,"parent_id":4821,"label":"1 day before","starts_at":"2026-09-20T00:00:00Z","ends_at":"2026-09-21T00:00:00Z","type":"Calendar::Countdown"}` +) + +func localDayJSON(kind, first, last, events, countdowns string) string { + return `{"kind":"` + kind + `","starts_at":"` + first + `T00:00:00Z","ends_at":"` + last + `T23:59:59Z","recordings":{"Calendar::Event":[` + + events + `],"Calendar::Countdown":[` + countdowns + `]}}` +} + +type localDayServer struct { + zone string + paths map[string]string + recordings string + failPath string + + mu sync.Mutex + requested []string + identityReads int +} + +func (s *localDayServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + s.requested = append(s.requested, r.URL.Path+"?"+r.URL.RawQuery) + if r.URL.Path == "/identity.json" { + s.identityReads++ + } + s.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + body, found := s.paths[r.URL.Path] + switch { + case r.URL.Path == s.failPath: + http.Error(w, `{"error":"unavailable"}`, http.StatusInternalServerError) + case r.URL.Path == "/identity.json": + zone := "null" + if s.zone != "" { + zone = `"` + s.zone + `"` + } + _, _ = io.WriteString(w, `{"id":1,"name":"Jason Fried","time_zone":`+zone+`}`) + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9102,"name":"Personal","owned":true,"personal":true}}]}`) + case r.URL.Path == "/calendars/9102/recordings.json": + _, _ = io.WriteString(w, s.recordings) + case found: + _, _ = io.WriteString(w, body) + case strings.HasPrefix(r.URL.Path, "/calendar/days/"): + date := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendar/days/"), ".json") + _, _ = io.WriteString(w, localDayJSON("day", date, date, "", "")) + default: + http.NotFound(w, r) + } +} + +func (s *localDayServer) readWidened() bool { + s.mu.Lock() + defer s.mu.Unlock() + for _, path := range s.requested { + if strings.Contains(path, "starts_on=2026-09-19") && strings.Contains(path, "ends_on=2026-09-22") { + return true + } + } + return false +} + +func runLocalDay(t *testing.T, server *localDayServer, args ...string) string { + t.Helper() + stdout, _, err := runFormattedCommandWithStderr(t, server, []string{"--json"}, args...) + if err != nil { + t.Fatalf("hey %s: %v", strings.Join(args, " "), err) + } + return stdout +} + +func TestEventsDayKeepsTheEveningEventWithItsCountdown(t *testing.T) { + server := &localDayServer{zone: "America/New_York", paths: map[string]string{ + "/calendar/days/2026-09-21.json": localDayJSON("day", "2026-09-21", "2026-09-21", localDayDinner, localDayCountdown), + }} + onTheEvening := runLocalDay(t, server, "event", "day", "2026-09-20") + if !strings.Contains(onTheEvening, "Late dinner with Priya Patel") || !strings.Contains(onTheEvening, "1 day before") { + t.Errorf("2026-09-20 = %s, want the dinner with its countdown", onTheEvening) + } + if onTheNextDay := runLocalDay(t, server, "event", "day", "2026-09-21"); strings.Contains(onTheNextDay, "Late dinner") { + t.Errorf("2026-09-21 = %s, want no dinner", onTheNextDay) + } +} + +func TestEventsWeekKeepsAnEventFromTheNeighbourDay(t *testing.T) { + server := &localDayServer{zone: "America/New_York", paths: map[string]string{ + "/calendar/weeks/2026-09-14.json": localDayJSON("week", "2026-09-14", "2026-09-20", localDayBreakfast, ""), + "/calendar/days/2026-09-21.json": localDayJSON("day", "2026-09-21", "2026-09-21", localDayDinner, ""), + }} + got := runLocalDay(t, server, "event", "week", "2026-09-14") + if !strings.Contains(got, "Late dinner") || !strings.Contains(got, "Breakfast with Maria Lopez") { + t.Errorf("week = %s, want both events", got) + } +} + +func TestEventsListWidensTheRequestAndDropsOtherDays(t *testing.T) { + server := &localDayServer{zone: "America/New_York", recordings: `{"Calendar::Event":[` + localDayDinner + `,` + localDayConcert + `]}`} + got := runLocalDay(t, server, "event", "list", "--calendar", "9102", "--starts-on", "2026-09-20", "--ends-on", "2026-09-20") + if !server.readWidened() { + t.Error("want the window widened by a day before and two days after") + } + if !strings.Contains(got, "Late dinner") || strings.Contains(got, "Evening concert") { + t.Errorf("list = %s, want the dinner and not the concert", got) + } +} + +func TestEventsEditFindsAnEveningEventByItsLocalDate(t *testing.T) { + server := &localDayServer{zone: "America/New_York", recordings: `{"Calendar::Event":[` + localDayDinner + `]}`} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPatch { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":4821,"title":"Dinner with Priya Patel"}`) + return + } + server.ServeHTTP(w, r) + }) + if _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-09-20", "--title", "Dinner with Priya Patel"); err != nil { + t.Fatalf("event edit: %v", err) + } + if !server.readWidened() { + t.Error("want the widened window") + } +} + +func TestEventsDayWithoutAnAccountZoneUsesTheMachinesZone(t *testing.T) { + inLocalZone(t, -4) + server := &localDayServer{paths: map[string]string{ + "/calendar/days/2026-09-21.json": localDayJSON("day", "2026-09-21", "2026-09-21", localDayDinner, ""), + }} + previousClock := clockNow + clockNow = func() time.Time { return time.Date(2026, 9, 20, 15, 0, 0, 0, time.UTC) } + t.Cleanup(func() { clockNow = previousClock }) + + stdout, stderr, err := runFormattedCommandWithStderr(t, server, []string{"--json"}, "event", "day") + if err != nil || !strings.Contains(stdout, "Late dinner") { + t.Fatalf("event day = %s, %v", stdout, err) + } + if strings.Count(stderr, "Notice:") != 1 || server.identityReads != 1 { + t.Errorf("notices in %q, identity reads %d, want one of each", stderr, server.identityReads) + } +} + +func TestEventsDayFailsWhenTheZoneOrANeighbourCannotBeRead(t *testing.T) { + for _, server := range []*localDayServer{ + {failPath: "/identity.json"}, + {zone: "UTC", failPath: "/calendar/days/2026-09-21.json"}, + } { + if _, err := runJSONCommand(t, server, "event", "day", "2026-09-20"); err == nil { + t.Errorf("a failed read of %s was ignored", server.failPath) + } + } +} diff --git a/internal/cmd/events_period.go b/internal/cmd/events_period.go index bfca14f5f..fd97772a3 100644 --- a/internal/cmd/events_period.go +++ b/internal/cmd/events_period.go @@ -11,6 +11,7 @@ import ( "github.com/basecamp/hey-sdk/go/pkg/generated" "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/calendarday" "github.com/basecamp/hey-cli/internal/output" ) @@ -66,6 +67,10 @@ outside it. Without a date the day is today in your HEY account's time zone, whatever this machine's is. An account with no time zone set reads this machine's today, and says so on stderr. +The date names a day in that same zone, so an event at 9:15 PM in New York is on its own +date and not on the next UTC date. The day before and the day after are read too, and an +event is listed when it overlaps the day. + The day covers the calendars switched on in HEY, the same set the app draws, so there is no --calendar to narrow it. A virtual occurrence carries the series in id and parent_id. A day HEY has written out on its own — after an edit of that day alone, or a reminder — @@ -109,6 +114,9 @@ Without a date the week is the one today falls in, today in your HEY account's t whatever this machine's is. An account with no time zone set reads this machine's today, and says so on stderr. +The days of the week are days in that same zone. The day before the week and the day +after it are read too, and an event is listed when it overlaps the week. + The week covers the calendars switched on in HEY, the same set the app draws, so there is no --calendar to narrow it. A virtual occurrence carries the series in id and parent_id. A day HEY has written out on its own — after an edit of that day alone, or a reminder — @@ -140,7 +148,8 @@ func (c *eventsPeriodCommand) run(cmd *cobra.Command, args []string) error { } ctx := cmd.Context() - date, described, err := c.date(ctx, cmd, args) + var account accountZone + date, described, err := c.date(ctx, cmd, &account, args) if err != nil { return err } @@ -150,15 +159,13 @@ func (c *eventsPeriodCommand) run(cmd *cobra.Command, args []string) error { return apierr.FromSDK(err) } - events := []generated.Recording{} - if period != nil { - events = filterRecordingsByType(&period.Recordings, recordingTypeEvent) + events, countdowns, err := localPeriodRecordings(ctx, cmd, &account, period) + if err != nil { + return err } sortEventsByStart(events) rows := occurrenceEventRows(events) - if period != nil { - attachEventCountdowns(rows, filterRecordingsByType(&period.Recordings, recordingTypeCountdown)) - } + attachEventCountdowns(rows, countdowns) total := len(rows) if c.limit > 0 && !c.all && len(rows) > c.limit { @@ -172,7 +179,7 @@ func (c *eventsPeriodCommand) run(cmd *cobra.Command, args []string) error { // date is the day the period is read around, and the words that name it. With no date given // it is today in the account's zone, sent as a date: HEY resolves "now" in UTC on a JSON // request, which in New York after 20:00 is tomorrow. -func (c *eventsPeriodCommand) date(ctx context.Context, cmd *cobra.Command, args []string) (string, string, error) { +func (c *eventsPeriodCommand) date(ctx context.Context, cmd *cobra.Command, account *accountZone, args []string) (string, string, error) { if len(args) > 0 { if _, err := parseDateArg("date", args[0]); err != nil { return "", "", err @@ -180,7 +187,6 @@ func (c *eventsPeriodCommand) date(ctx context.Context, cmd *cobra.Command, args return args[0], c.describe(args[0], false), nil } - var account accountZone today, err := account.todayToRead(ctx, cmd.ErrOrStderr(), c.todayHint) if err != nil { return "", "", err @@ -189,6 +195,60 @@ func (c *eventsPeriodCommand) date(ctx context.Context, cmd *cobra.Command, args return date, c.describe(date, true), nil } +// localPeriodRecordings are the events and countdowns of a period's local days. HEY filed an +// event under its UTC day, so the days either side are read too, and a countdown that the +// reads share is listed once. +func localPeriodRecordings(ctx context.Context, cmd *cobra.Command, account *accountZone, period *generated.CalendarPeriod) ([]generated.Recording, []generated.Recording, error) { + if period == nil { + return []generated.Recording{}, nil, nil + } + if period.StartsAt.IsZero() { + return filterRecordingsByType(&period.Recordings, recordingTypeEvent), filterRecordingsByType(&period.Recordings, recordingTypeCountdown), nil + } + + loc, err := account.locationToRead(ctx, cmd.ErrOrStderr()) + if err != nil { + return nil, nil, err + } + readDay := func(ctx context.Context, date string) (*generated.CalendarPeriod, error) { + neighbour, readErr := sdk.CalendarPeriods().Day(ctx, date) + if readErr != nil { + return nil, apierr.FromSDK(readErr) + } + return neighbour, nil + } + neighbours, err := calendarday.ReadNeighbours(ctx, period, readDay) + if err != nil { + return nil, nil, err + } + + events := calendarday.LocalEvents(period, neighbours, loc) + countdowns := filterRecordingsByType(&period.Recordings, recordingTypeCountdown) + for _, neighbour := range neighbours { + if neighbour != nil { + countdowns = append(countdowns, filterRecordingsByType(&neighbour.Recordings, recordingTypeCountdown)...) + } + } + return events, distinctByID(countdowns), nil +} + +// distinctByID drops a recording whose id an earlier one already had, so a countdown read +// under two days is listed once. A recording with no id is always kept. +func distinctByID(recordings []generated.Recording) []generated.Recording { + seen := map[int64]bool{} + distinct := make([]generated.Recording, 0, len(recordings)) + for _, recording := range recordings { + if recording.Id != 0 { + if seen[recording.Id] { + continue + } + seen[recording.Id] = true + } + distinct = append(distinct, recording) + } + return distinct +} + // eventRow is the event shape the CLI publishes. A virtual occurrence's id names its // series; a realized day's id and RecordingID both name its own event. type eventRow struct { diff --git a/internal/cmd/events_period_test.go b/internal/cmd/events_period_test.go index 07da1c0ff..094ef3e8d 100644 --- a/internal/cmd/events_period_test.go +++ b/internal/cmd/events_period_test.go @@ -15,13 +15,37 @@ import ( "github.com/basecamp/hey-cli/internal/apierr" ) +func emptyDayJSON(date string) string { + return `{"kind":"day","starts_at":"` + date + `T00:00:00Z","ends_at":"` + date + `T23:59:59Z","recordings":{}}` +} + +func withLocalDays(days map[string]string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/identity.json" { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":1,"name":"Jason Fried","time_zone":"UTC"}`) + return + } + if r.Method == http.MethodGet { + date := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendar/days/"), ".json") + if body, ok := days[date]; ok { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + return + } + } + next.ServeHTTP(w, r) + }) +} + // A repeating event is one row on a calendar, so `hey event list` answers it on the day the // series began. A day is HEY's own expansion: the occurrence falls on the day asked for, // carrying that day's times, and everything that is not an event stays out of the answer. // HEY serves the occurrence virtual — no id of its own, the series in parent_id — and the // row resolves that to the series id, which is what edit and delete take. func TestEventsDayExpandsRecurringEvents(t *testing.T) { - response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + neighbours := map[string]string{"2026-09-01": emptyDayJSON("2026-09-01"), "2026-09-03": emptyDayJSON("2026-09-03")} + response, err := runJSONCommand(t, withLocalDays(neighbours, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/calendar/days/2026-09-02.json" { t.Errorf("request = %s %s, want the day read", r.Method, r.URL.Path) http.NotFound(w, r) @@ -35,7 +59,7 @@ func TestEventsDayExpandsRecurringEvents(t *testing.T) { `],`+ `"Calendar::Habit":[{"id":11,"title":"Morning strength training"}],`+ `"Calendar::Todo":[{"id":3,"title":"Send notes"}]}}`) - }), "event", "day", "2026-09-02") + })), "event", "day", "2026-09-02") if err != nil { t.Fatalf("execute event day: %v", err) } @@ -268,7 +292,8 @@ func TestEventsDayKeepsARealizedOccurrenceID(t *testing.T) { http.NotFound(w, r) } }) - response, err := runJSONCommand(t, handler, "event", "day", "2026-09-15") + neighbours := map[string]string{"2026-09-14": emptyDayJSON("2026-09-14"), "2026-09-16": emptyDayJSON("2026-09-16")} + response, err := runJSONCommand(t, withLocalDays(neighbours, handler), "event", "day", "2026-09-15") if err != nil { t.Fatalf("execute event day: %v", err) } @@ -338,7 +363,8 @@ func TestEventsPeriodStyledPublishesOccurrenceIdentifiers(t *testing.T) { } func TestEventsWeekReadsTheWeekPeriod(t *testing.T) { - response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + neighbours := map[string]string{"2026-08-30": emptyDayJSON("2026-08-30"), "2026-09-07": emptyDayJSON("2026-09-07")} + response, err := runJSONCommand(t, withLocalDays(neighbours, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/calendar/weeks/2026-09-02.json" { t.Errorf("request = %s %s, want the week read", r.Method, r.URL.Path) http.NotFound(w, r) @@ -350,7 +376,7 @@ func TestEventsWeekReadsTheWeekPeriod(t *testing.T) { `{"title":"Standup","starts_at":"2026-09-04T09:15:00Z","ends_at":"2026-09-04T09:30:00Z","type":"Calendar::Event","recurring":true,"parent_id":204,"occurrence_id":"204_2026-09-04","calendar":{"id":9,"name":"Work"}},`+ `{"title":"Standup","starts_at":"2026-09-02T09:15:00Z","ends_at":"2026-09-02T09:30:00Z","type":"Calendar::Event","recurring":true,"parent_id":204,"occurrence_id":"204_2026-09-02","calendar":{"id":9,"name":"Work"}}`+ `]}}`) - }), "event", "week", "2026-09-02") + })), "event", "week", "2026-09-02") if err != nil { t.Fatalf("execute event week: %v", err) } diff --git a/internal/cmd/events_test.go b/internal/cmd/events_test.go index e1bde9820..1a6627984 100644 --- a/internal/cmd/events_test.go +++ b/internal/cmd/events_test.go @@ -309,7 +309,7 @@ func TestEventsEditAllDayEventOmitsClockTimes(t *testing.T) { } // Giving a date reads that day rather than a window around today — as the window -// [day, day+1), since HEY's recordings window is a pair of instants and the degenerate +// [day-1, day+2), since HEY's recordings window is a pair of instants and the degenerate // same-day window can never contain a timed event. func TestEventsEditReadsTheDayItIsGiven(t *testing.T) { _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -318,11 +318,11 @@ func TestEventsEditReadsTheDayItIsGiven(t *testing.T) { case r.URL.Path == "/calendars.json": _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Work","owned":true}}]}`) case r.URL.Path == "/calendars/9/recordings.json": - if got := r.URL.Query().Get("starts_on"); got != "2026-09-02" { - t.Errorf("starts_on = %q", got) + if got := r.URL.Query().Get("starts_on"); got != "2026-09-01" { + t.Errorf("starts_on = %q, want the day before", got) } - if got := r.URL.Query().Get("ends_on"); got != "2026-09-03" { - t.Errorf("ends_on = %q, want the day after so a timed event is inside the window", got) + if got := r.URL.Query().Get("ends_on"); got != "2026-09-04" { + t.Errorf("ends_on = %q, want two days after so a timed event is inside the window", got) } _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":4821,"title":"Design review","all_day":true,"starts_at":"2026-09-02T00:00:00Z","ends_at":"2026-09-02T00:00:00Z"}]}`) case r.Method == http.MethodPatch: diff --git a/internal/cmd/recording_filter.go b/internal/cmd/recording_filter.go index 4b4a00021..bcd931872 100644 --- a/internal/cmd/recording_filter.go +++ b/internal/cmd/recording_filter.go @@ -42,7 +42,7 @@ type recordingFilter struct { // eventWindow reads from today forward, which is what somebody asking what is on their // calendar means. func eventWindow(today time.Time) (time.Time, time.Time) { - return today, today.AddDate(0, 0, 30) + return today, today.AddDate(0, 0, 29) } // personalWindow reads years back and a year forward. A to-do or a journal entry is looked up diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index 5ea405c69..bf3dfaac0 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -15,6 +15,7 @@ import ( "github.com/basecamp/hey-sdk/go/pkg/generated" hey "github.com/basecamp/hey-sdk/go/pkg/hey" + "github.com/basecamp/hey-cli/internal/calendarday" "github.com/basecamp/hey-cli/internal/terminal" ) @@ -2535,6 +2536,7 @@ func toggleNotice(name string, on bool) string { // it was created. func (v *calendarView) requestRecordings() tea.Cmd { date := v.day().Format("2006-01-02") + loc := v.day().Location() mode := v.viewMode requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestRecordings) return func() tea.Msg { @@ -2560,7 +2562,12 @@ func (v *calendarView) requestRecordings() tea.Cmd { if err != nil { return recordingsLoadedMsg{requestResult: newRequestResult(requestID, err)} } - return recordingsLoadedMsg{requestResult: newRequestResult(requestID, nil), recordings: recordingsIn(period)} + neighbours, err := calendarday.ReadNeighbours(ctx, period, periods.Day) + if err != nil { + return recordingsLoadedMsg{requestResult: newRequestResult(requestID, err)} + } + local := calendarday.WithLocalEvents(period, neighbours, loc) + return recordingsLoadedMsg{requestResult: newRequestResult(requestID, nil), recordings: recordingsIn(local)} } } diff --git a/internal/tui/calendar_local_day_test.go b/internal/tui/calendar_local_day_test.go new file mode 100644 index 000000000..486b6c495 --- /dev/null +++ b/internal/tui/calendar_local_day_test.go @@ -0,0 +1,109 @@ +package tui + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + hey "github.com/basecamp/hey-sdk/go/pkg/hey" +) + +func newYork(t *testing.T) *time.Location { + t.Helper() + loc, err := time.LoadLocation("America/New_York") + if err != nil { + t.Skipf("time zone database unavailable: %v", err) + } + return loc +} + +func dayJSON(date string, events ...string) string { + return `{"starts_at":"` + date + `T00:00:00Z","ends_at":"` + date + `T23:59:59Z","kind":"day","recordings":{"Calendar::Event":[` + strings.Join(events, ",") + `]}}` +} + +func TestRequestRecordingsKeepsTheEventsOfTheLocalDay(t *testing.T) { + zone := newYork(t) + const dinner = `{"id":4821,"title":"Late dinner with Priya Patel","type":"CalendarEvent","starts_at":"2026-09-21T00:50:00Z","ends_at":"2026-09-21T02:40:00Z"}` + const concert = `{"id":4822,"title":"Evening concert","type":"CalendarEvent","starts_at":"2026-09-18T22:00:00Z","ends_at":"2026-09-19T01:30:00Z"}` + answers := map[string]string{ + "/calendar/days/2026-09-19.json": dayJSON("2026-09-19", concert), + "/calendar/days/2026-09-21.json": dayJSON("2026-09-21", dinner), + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body, ok := answers[req.URL.Path] + if !ok { + body = dayJSON(strings.TrimSuffix(strings.TrimPrefix(req.URL.Path, "/calendar/days/"), ".json")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + + vc := testVC() + vc.sdk = hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "test-token"}, hey.WithMaxRetries(0)) + cases := []struct { + day int + title string + wantShown bool + }{ + {20, "Late dinner", true}, + {21, "Late dinner", false}, + {19, "Evening concert", false}, + } + for _, tc := range cases { + v := newCalendarView(vc) + v.now = func() time.Time { return time.Date(2026, 9, tc.day, 12, 0, 0, 0, zone) } + msg := v.requestRecordings()().(recordingsLoadedMsg) + if msg.err != nil { + t.Fatalf("read of 2026-09-%d: %v", tc.day, msg.err) + } + shown := false + for _, recording := range msg.recordings { + shown = shown || strings.HasPrefix(recording.Title, tc.title) + } + if shown != tc.wantShown { + t.Errorf("2026-09-%d shows %q = %v, want %v", tc.day, tc.title, shown, tc.wantShown) + } + } +} + +func TestDayColumnsClipASpanningEventToTheDay(t *testing.T) { + zone := newYork(t) + const daySpan = 96 + dinner := Recording{StartsAt: at("2026-09-21T00:50:00Z").In(zone), EndsAt: at("2026-09-21T04:20:00Z").In(zone)} + stay := Recording{StartsAt: at("2026-09-16T18:00:00Z").In(zone), EndsAt: at("2026-09-18T15:00:00Z").In(zone)} + cases := []struct { + name string + event Recording + day int + start, end int + }{ + {"dinner on its evening", dinner, 20, 83, daySpan}, + {"dinner after midnight", dinner, 21, 0, 1}, + {"stay on its first day", stay, 16, 56, daySpan}, + {"stay on a middle day", stay, 17, 0, daySpan}, + {"stay on its last day", stay, 18, 0, 44}, + } + for _, tc := range cases { + anchor := time.Date(2026, 9, tc.day, 12, 0, 0, 0, zone) + if start, end := dayColumns(tc.event.StartsAt, tc.event.EndsAt, anchor, daySpan); start != tc.start || end != tc.end { + t.Errorf("%s: columns %d..%d, want %d..%d", tc.name, start, end, tc.start, tc.end) + } + } +} + +func TestRenderDayViewStartsAMultiDayStayAtTheFirstHour(t *testing.T) { + zone := newYork(t) + stay := []Recording{{ID: 4823, Title: "Conference hotel stay", Type: "Calendar::Event", + StartsAt: at("2026-09-16T18:00:00Z"), EndsAt: at("2026-09-18T15:00:00Z")}} + anchor := time.Date(2026, 9, 17, 12, 0, 0, 0, zone) + + lines := strings.Split(stripANSI(renderDayView(stay, nil, nil, anchor, calendarFixtureNow(), "", 100, 14, selection{}, nil, true)), "\n") + for i, line := range lines[2:] { + if first := []rune(line)[0]; first == hourRule { + t.Errorf("grid row %d is ruled at midnight, so the stay does not start there: %q", i, line) + } + } +} diff --git a/internal/tui/calendar_test.go b/internal/tui/calendar_test.go index bc8c68fd7..8d1b2f996 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -5,6 +5,8 @@ import ( "image/color" "net/http" "net/http/httptest" + "slices" + "sort" "strings" "sync" "testing" @@ -478,7 +480,7 @@ func TestCalendarViewFetchesAroundTheCurrentDay(t *testing.T) { } } -// The week reads the week the day falls in — one request, not seven days' worth. +// The week reads the week the day falls in — three requests, not seven days' worth. func TestCalendarViewReadsTheWeekForTheWeekSpan(t *testing.T) { var mu sync.Mutex var paths []string @@ -501,8 +503,10 @@ func TestCalendarViewReadsTheWeekForTheWeekSpan(t *testing.T) { v.requestRecordings()() mu.Lock() defer mu.Unlock() - if len(paths) != 1 || paths[0] != "/calendar/weeks/2025-03-09.json" { - t.Errorf("paths = %v, want one read of the week", paths) + sort.Strings(paths) + want := []string{"/calendar/days/2025-03-02.json", "/calendar/days/2025-03-10.json", "/calendar/weeks/2025-03-09.json"} + if !slices.Equal(paths, want) { + t.Errorf("paths = %v, want %v", paths, want) } } diff --git a/internal/tui/calendar_views.go b/internal/tui/calendar_views.go index 26bffe686..04d7351a1 100644 --- a/internal/tui/calendar_views.go +++ b/internal/tui/calendar_views.go @@ -698,11 +698,7 @@ func renderDayView(events, habits, countdowns []Recording, anchor, now time.Time et = st.Add(time.Hour) } - startPos := (st.Hour()*60 + st.Minute()) * daySpan / (24 * 60) - endPos := (et.Hour()*60 + et.Minute()) * daySpan / (24 * 60) - if et.Day() != st.Day() || (et.Hour() == 0 && et.Minute() == 0 && et.After(st)) { - endPos = daySpan - } + startPos, endPos := dayColumns(st, et, anchor, daySpan) if endPos <= startPos { endPos = startPos + colWidth } @@ -773,6 +769,24 @@ func renderDayView(events, habits, countdowns []Recording, anchor, now time.Time return strings.TrimRight(b.String(), "\n") } +// dayColumns is the grid columns an event fills on the day of anchor. An event that began +// before the day starts at its first column, and one that ends after it runs to its last, so +// a stay over several days is drawn whole on each day it touches. +func dayColumns(starts, ends, anchor time.Time, daySpan int) (int, int) { + loc := anchor.Location() + dayStart := time.Date(anchor.Year(), anchor.Month(), anchor.Day(), 0, 0, 0, 0, loc) + dayEnd := time.Date(anchor.Year(), anchor.Month(), anchor.Day()+1, 0, 0, 0, 0, loc) + + startPos, endPos := 0, daySpan + if !starts.Before(dayStart) { + startPos = (starts.Hour()*60 + starts.Minute()) * daySpan / (24 * 60) + } + if ends.Before(dayEnd) { + endPos = (ends.Hour()*60 + ends.Minute()) * daySpan / (24 * 60) + } + return startPos, endPos +} + // renderDayGrid draws the day's hours as one canvas: the lanes of events stacked down // it, and an hour rule falling down every hour column no event stands on. The rules are // the grid, not a decoration on the events, so a day with nothing on it still reads as diff --git a/internal/tui/habit_form_test.go b/internal/tui/habit_form_test.go index 8c518c2ee..c8ec7c89c 100644 --- a/internal/tui/habit_form_test.go +++ b/internal/tui/habit_form_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "slices" "strings" "sync" "testing" @@ -223,6 +224,9 @@ func calendarHabitsWithServer(t *testing.T) (*calendarView, *recordedHabitReques case req.Method == http.MethodGet && req.URL.Path == habitRefreshPath: _, _ = io.WriteString(w, `{"starts_at":"2025-03-09T00:00:00Z","ends_at":"2025-03-09T23:59:59Z","kind":"day", "recordings":{"Calendar::Habit":[{"id":7,"title":"Read before bed","type":"CalendarHabit","icon":"read","color":"blue","days":[1,3,5]}]}}`) + case req.Method == http.MethodGet && strings.HasPrefix(req.URL.Path, "/calendar/days/"): + date := strings.TrimSuffix(strings.TrimPrefix(req.URL.Path, "/calendar/days/"), ".json") + _, _ = io.WriteString(w, `{"starts_at":"`+date+`T00:00:00Z","ends_at":"`+date+`T23:59:59Z","kind":"day","recordings":{}}`) default: http.NotFound(w, req) } @@ -395,8 +399,9 @@ func TestCalendarHabitEnterCompletesAndClearsForTheDayOnScreen(t *testing.T) { t.Errorf("toast = %q", toast) } requests, _ = recorded.snapshot() - if got := requests[len(requests)-2]; got != "DELETE /calendar/days/2026-08-22/habits/7/completions.json" { - t.Errorf("clearing request = %q", got) + clearing := slices.Index(requests, "DELETE /calendar/days/2026-08-22/habits/7/completions.json") + if clearing < 0 || clearing+1 >= len(requests) || requests[clearing+1] != "GET /calendar/days/2026-08-22.json" { + t.Errorf("clearing requests = %v", requests) } }