diff --git a/docs/cli.md b/docs/cli.md index 2b99fbb02..e7b785a92 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -422,9 +422,11 @@ day`, `week` and `list`, `hey habit list`, `complete` and `uncomplete`, `hey jou and `write`, and `hey todo add` — works it out in your HEY account's time zone and sends the date. HEY would read "now" in UTC, and a server's clock is UTC too, so in New York after 20:00 either would name tomorrow. Resolving today's date reads the identity once for -the account's zone, and naming the date skips that read. A specific `--account` is validated -by a separate identity read before the command runs, so a defaulted command with it reads -the identity twice and a dated command once. If the account has no zone set, or one this +the account's zone, and naming the date skips that read, except for `hey event day`, `week` +and `list`, which read the zone for any date to pick the local days and show times in it. A +specific `--account` is validated by a separate identity read before the command runs, so a +defaulted command with it reads the identity twice and a dated command once, while those +three read it twice either way. If the account has no zone set, or one this build does not know, a read uses this machine's today and says so on stderr, while a write is refused and asks you to name the date. A failed read of the account is an error either way, with the code it failed with; a sign-in failure is reported as one. `hey todo list` and `hey @@ -434,7 +436,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 @@ -462,6 +464,19 @@ 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 also reads the UTC +day that the local day (or week) reaches into: the day after west of UTC, the day before east +of it, and neither in UTC. It 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. The table shows times in that +same zone, and `--json` keeps HEY's UTC times. + +`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..5d1e3673d 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -214,6 +214,11 @@ 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. HEY reads a date as a UTC day, so the app also reads the UTC day +that the local day (or week) reaches into: the day after west of UTC, the day before east of +it, and neither in UTC. 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..a52d0871e --- /dev/null +++ b/internal/calendarday/calendarday.go @@ -0,0 +1,238 @@ +// 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 UTC days outside a period that its local days reach into, at the +// same time. West of UTC that is the day after, east of it the day before, and in UTC none, +// so no request is made that cannot find an event. 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, loc *time.Location, readDay DayReader) ([]*generated.CalendarPeriod, error) { + if main == nil || main.StartsAt.IsZero() { + return nil, nil + } + dates := SpanOf(main).Neighbours(loc) + if len(dates) == 0 { + return nil, nil + } + 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(StartOfDay(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, StartOfDay(first, loc), StartOfDay(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) +} + +// ReachesDayBefore reports whether the local day of the UTC date first starts before that +// date does, so HEY files some of its events under the UTC day before. That is the case east +// of UTC. Each edge uses its own offset, so a clock change is right. +func ReachesDayBefore(first time.Time, loc *time.Location) bool { + return StartOfDay(first, loc).Before(first) +} + +// ReachesDayAfter reports whether the local day after the UTC date last starts after that +// date's day ends, so HEY files some of the events of last under the UTC day after. That is +// the case west of UTC. +func ReachesDayAfter(last time.Time, loc *time.Location) bool { + next := last.AddDate(0, 0, 1) + return StartOfDay(next, loc).After(next) +} + +// StartOfDay is the first instant of a date in the reader's zone, the date being the one day +// carries. time.Date alone is wrong where a clock change skips or repeats midnight: a skipped +// midnight is moved back to the day before, and a repeated one may be either of its two. +func StartOfDay(day time.Time, loc *time.Location) time.Time { + y, m, d := day.Date() + t := time.Date(y, m, d, 0, 0, 0, 0, loc) + if ty, tm, td := t.Date(); ty != y || tm != m || td != d { + _, end := t.ZoneBounds() + return end + } + start, _ := t.ZoneBounds() + if !start.IsZero() { + _, offset := start.Add(-time.Nanosecond).Zone() + earlier := time.Date(y, m, d, 0, 0, 0, 0, time.UTC).Add(-time.Duration(offset) * time.Second) + if earlier.Before(start) { + return earlier + } + } + return t +} + +// 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) +} + +// 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 that its local days reach +// into, the day before first and then the day after. HEY can file an event of the span's +// local days under either of them. +func (s Span) Neighbours(loc *time.Location) []string { + var dates []string + if ReachesDayBefore(s.First, loc) { + dates = append(dates, s.First.AddDate(0, 0, -1).Format(dateLayout)) + } + if ReachesDayAfter(s.Last, loc) { + dates = append(dates, s.Last.AddDate(0, 0, 1).Format(dateLayout)) + } + return dates +} + +// 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 StartOfDay(s.First, loc), StartOfDay(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..49b671145 --- /dev/null +++ b/internal/calendarday/calendarday_test.go @@ -0,0 +1,238 @@ +package calendarday + +import ( + "context" + "slices" + "strings" + "sync" + "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, neighbours string + hours int + }{ + {"week", "2026-09-14T00:00:00Z", "2026-09-20T23:59:59Z", "2026-09-20", "2026-09-21", 7 * 24}, + {"end at midnight", "2026-09-14T00:00:00Z", "2026-09-21T00:00:00Z", "2026-09-20", "2026-09-21", 7 * 24}, + {"clock change", "2026-11-01T00:00:00Z", "2026-11-01T23:59:59Z", "2026-11-01", "2026-11-02", 25}, + } + for _, tc := range cases { + span := SpanOf(&generated.CalendarPeriod{StartsAt: instant(t, tc.starts), EndsAt: instant(t, tc.ends)}) + neighbours := strings.Join(span.Neighbours(newYork), " ") + from, to := span.Local(newYork) + if span.Last.Format(dateLayout) != tc.last || neighbours != tc.neighbours || int(to.Sub(from).Hours()) != tc.hours { + t.Errorf("%s: last %s, neighbours %q, length %s", tc.name, span.Last.Format(dateLayout), neighbours, to.Sub(from)) + } + } +} + +func TestReadNeighboursReadsOnlyTheDaysTheZoneReaches(t *testing.T) { + cases := []struct { + name string + zone string + first, last string + want []string + }{ + {"New York, day", "America/New_York", "2026-09-20", "2026-09-20", []string{"2026-09-21"}}, + {"Tokyo, day", "Asia/Tokyo", "2026-09-20", "2026-09-20", []string{"2026-09-19"}}, + {"UTC, day", "UTC", "2026-09-20", "2026-09-20", nil}, + {"New York, week", "America/New_York", "2026-09-14", "2026-09-20", []string{"2026-09-21"}}, + {"London, BST ends", "Europe/London", "2027-10-31", "2027-10-31", []string{"2027-10-30"}}, + {"London, BST starts", "Europe/London", "2026-03-29", "2026-03-29", nil}, + {"New York, EDT ends", "America/New_York", "2026-11-01", "2026-11-01", []string{"2026-11-02"}}, + {"Kolkata, day", "Asia/Kolkata", "2026-09-20", "2026-09-20", []string{"2026-09-19"}}, + {"St John's, day", "America/St_Johns", "2026-09-20", "2026-09-20", []string{"2026-09-21"}}, + } + for _, tc := range cases { + loc, err := time.LoadLocation(tc.zone) + if err != nil { + t.Skipf("time zone database unavailable: %v", err) + } + var mu sync.Mutex + var asked []string + readDay := func(_ context.Context, date string) (*generated.CalendarPeriod, error) { + mu.Lock() + defer mu.Unlock() + asked = append(asked, date) + return period(t, date, date), nil + } + main := period(t, tc.first, tc.last) + periods, err := ReadNeighbours(t.Context(), main, loc, readDay) + slices.Sort(asked) + if err != nil || !slices.Equal(asked, tc.want) || len(periods) != len(tc.want) { + t.Errorf("%s: asked %v, read %d periods, error %v, want %v", tc.name, asked, len(periods), err, tc.want) + } + } +} + +func TestStartOfDay(t *testing.T) { + cases := []struct { + name string + zone string + date string + want string + }{ + {"Santiago, skipped midnight", "America/Santiago", "2026-09-06", "2026-09-06T04:00:00Z"}, + {"Santiago, the day before", "America/Santiago", "2026-09-05", "2026-09-05T04:00:00Z"}, + {"Havana, repeated midnight", "America/Havana", "2026-11-01", "2026-11-01T04:00:00Z"}, + {"New York", "America/New_York", "2026-09-20", "2026-09-20T04:00:00Z"}, + {"Tokyo", "Asia/Tokyo", "2026-09-20", "2026-09-19T15:00:00Z"}, + } + for _, tc := range cases { + loc, err := time.LoadLocation(tc.zone) + if err != nil { + t.Skipf("time zone database unavailable: %v", err) + } + if got := StartOfDay(instant(t, tc.date+"T00:00:00Z"), loc); !got.Equal(instant(t, tc.want)) { + t.Errorf("%s: StartOfDay = %s, want %s", tc.name, got.UTC().Format(time.RFC3339), tc.want) + } + } +} + +func TestInDaysPlacesAnEventBeforeASkippedMidnightOnTheDayBefore(t *testing.T) { + santiago, err := time.LoadLocation("America/Santiago") + if err != nil { + t.Skipf("time zone database unavailable: %v", err) + } + event := generated.Recording{StartsAt: instant(t, "2026-09-06T03:15:00Z"), EndsAt: instant(t, "2026-09-06T03:45:00Z")} + for date, want := range map[string]bool{"2026-09-05": true, "2026-09-06": false} { + day := instant(t, date+"T00:00:00Z") + if got := InDays(event, day, day, santiago); got != want { + t.Errorf("%s: InDays = %v, want %v", date, got, want) + } + } +} + +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..1452defe9 100644 --- a/internal/cmd/account_zone_test.go +++ b/internal/cmd/account_zone_test.go @@ -121,15 +121,22 @@ type todayCommand struct { args []string sends func(day string) string write bool + + sendsEastOfUTC func(day string) string + + readsZoneForDays bool } var todayCommands = []todayCommand{ - {name: "event day", args: []string{"event", "day"}, sends: func(day string) string { return "GET /calendar/days/" + day }}, - {name: "event week", args: []string{"event", "week"}, sends: func(day string) string { return "GET /calendar/weeks/" + day }}, + {name: "event day", args: []string{"event", "day"}, sends: func(day string) string { return "GET /calendar/days/" + day }, readsZoneForDays: true}, + {name: "event week", args: []string{"event", "week"}, sends: func(day string) string { return "GET /calendar/weeks/" + day }, readsZoneForDays: true}, {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.Format(dateLayout) + " ends_on=" + start.AddDate(0, 0, 31).Format(dateLayout) + }, sendsEastOfUTC: func(day string) string { + start, _ := time.Parse(dateLayout, day) + return "GET /calendars/9/recordings starts_on=" + start.AddDate(0, 0, -1).Format(dateLayout) + " ends_on=" + start.AddDate(0, 0, 30).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 +182,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 +// event commands, which cut events to local days and show their times in that zone. 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}) @@ -195,8 +206,8 @@ func TestANamedDayReadsNoAccountZone(t *testing.T) { } // A specific --account is validated by the SDK with an identity read before the command -// runs. Resolving an omitted date makes its own read for the zone; naming the date skips -// that read, but not account validation. +// runs. The event commands read the zone to show times in, so a named date costs the same +// two reads as an omitted one. func TestAccountSelectionReadsIdentitySeparatelyFromToday(t *testing.T) { for _, tt := range []struct { name string @@ -205,7 +216,7 @@ func TestAccountSelectionReadsIdentitySeparatelyFromToday(t *testing.T) { identityReads int32 }{ {name: "default date", args: []string{"--account", "2", "event", "day"}, wantDay: "2026-10-14", identityReads: 2}, - {name: "named date", args: []string{"--account", "2", "event", "day", "2026-03-15"}, wantDay: "2026-03-15", identityReads: 1}, + {name: "named date", args: []string{"--account", "2", "event", "day", "2026-03-15"}, wantDay: "2026-03-15", identityReads: 2}, } { t.Run(tt.name, func(t *testing.T) { atInstantOn(t, "2026-10-15T02:00:00Z", "UTC") @@ -241,13 +252,18 @@ func TestAReadWithoutAnAccountZoneTakesTheMachinesToday(t *testing.T) { } t.Run(fixture.name+"/"+command.name, func(t *testing.T) { atInstantOn(t, "2026-10-15T12:00:00Z", "Pacific/Kiritimati") + inLocalZone(t, 14) handler, requests := todayServer(t, fixture.fixture) stdout, stderr, err := runFormattedCommandWithStderr(t, handler, []string{"--json"}, command.args...) if err != nil { t.Fatalf("execute %s: %v", command.name, err) } - if sent := requests.all(); !strings.Contains(sent, command.sends("2026-10-16")) { - t.Errorf("requests =\n%s\nwant %q", sent, command.sends("2026-10-16")) + sends := command.sends + if command.sendsEastOfUTC != nil { + sends = command.sendsEastOfUTC + } + if sent := requests.all(); !strings.Contains(sent, sends("2026-10-16")) { + t.Errorf("requests =\n%s\nwant %q", sent, sends("2026-10-16")) } want := "Notice: " + fixture.reason + ", so today is 2026-10-16 by this machine's clock" if !strings.Contains(stderr, want) { diff --git a/internal/cmd/calendar_commands_test.go b/internal/cmd/calendar_commands_test.go index 0eb9324a8..20fcc9716 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) + t.Errorf("starts_on = %q, want the first day, as UTC reaches into no day before it", 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-01" { + t.Errorf("ends_on = %q, want one day after the last day, as UTC reaches into no day beyond it", 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) { + 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-03" { - t.Errorf("ends_on = %q, want 30 days after 2026-02-01", got) + t.Errorf("ends_on = %q, want the 30th day from 2026-02-01, and one day more", 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 0590eb45c..a44762b4e 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" @@ -70,7 +71,14 @@ 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. + +The table shows times in that same zone, and --json keeps HEY's UTC times.`, Annotations: map[string]string{ "agent_notes": "list carries no countdowns; day and week do. " + recordingWindowNotes, }, @@ -104,7 +112,12 @@ func (c *eventsListCommand) run(cmd *cobra.Command, args []string) error { return err } - events, err := window.read(ctx, recordingTypeEvent) + loc, err := account.locationToRead(ctx, cmd.ErrOrStderr()) + if err != nil { + return err + } + + events, err := readEventsOfLocalDays(ctx, window, loc) if err != nil { return err } @@ -115,19 +128,55 @@ func (c *eventsListCommand) run(cmd *cobra.Command, args []string) error { } notice := output.TruncationNotice(len(events), total) - return writeEventRows(cmd, eventRows(events), window.describe(), notice) + return writeEventRows(cmd, eventRows(events), window.describe(), notice, loc) +} + +// 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 the UTC day the zone reaches into on each +// side, and keeps what falls on the days named. +func readEventsOfLocalDays(ctx context.Context, window recordingWindow, loc *time.Location) ([]generated.Recording, error) { + 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.Format(dateLayout) + if calendarday.ReachesDayBefore(first, loc) { + wide.startsOn = first.AddDate(0, 0, -1).Format(dateLayout) + } + wide.endsOn = last.AddDate(0, 0, 1).Format(dateLayout) + if calendarday.ReachesDayAfter(last, loc) { + 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 -// converts to the reader's clock the way HEY's own views draw it, while an all-day date is -// the day it names and does not shift. -func eventBoundary(at time.Time, allDay bool) string { +// converts to the zone the days were picked in, the way HEY's own views draw it, while an +// all-day date is the day it names and does not shift. +func eventBoundary(at time.Time, allDay bool, loc *time.Location) string { if allDay { - return formatDate(at) + return formatDate(at.UTC()) } - return formatTimestamp(at.Local()) + return formatTimestamp(at.In(loc)) } // add @@ -282,8 +331,10 @@ the later where neither is; a time you keep at the other, or with seconds, canno 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. Without [date] it is looked for within a year either side of today, and an event not +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. +Without [date] it is looked for within a year either side of today, and an event not found there is refused as not_found rather than written blind. --calendar moves the event to that calendar, which has to be one you own or share: not your @@ -578,10 +629,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 { @@ -610,14 +661,16 @@ 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. It reads no zone, since +// that costs a request of its own, and the wider window costs none. 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..c90f2c850 --- /dev/null +++ b/internal/cmd/events_local_day_test.go @@ -0,0 +1,226 @@ +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) readWindow(startsOn, endsOn string) bool { + s.mu.Lock() + defer s.mu.Unlock() + for _, path := range s.requested { + if strings.Contains(path, "starts_on="+startsOn) && strings.Contains(path, "ends_on="+endsOn) { + return true + } + } + return false +} + +func (s *localDayServer) dayReads() int { + s.mu.Lock() + defer s.mu.Unlock() + reads := 0 + for _, path := range s.requested { + if strings.HasPrefix(path, "/calendar/days/") { + reads++ + } + } + return reads +} + +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.readWindow("2026-09-20", "2026-09-22") { + t.Error("want the window widened by two days after and none before") + } + 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.readWindow("2026-09-19", "2026-09-22") { + t.Error("want the widened window") + } +} + +const ( + zonedSupper = `{"id":4901,"title":"Supper with Maria Lopez","starts_at":"2026-09-21T01:30:00Z","ends_at":"2026-09-21T02:30:00Z","type":"Calendar::Event"}` + zonedBreakfast = `{"id":4902,"title":"Early breakfast with Priya Patel","starts_at":"2026-09-19T15:30:00Z","ends_at":"2026-09-19T16:30:00Z","type":"Calendar::Event"}` + zonedHoliday = `{"id":4903,"title":"Company holiday","all_day":true,"starts_at":"2026-09-20T00:00:00Z","ends_at":"2026-09-20T00:00:00Z","type":"Calendar::Event"}` +) + +func TestEventsDayPrintsTimesInTheAccountZone(t *testing.T) { + inLocalZone(t, 0) + server := &localDayServer{zone: "America/New_York", paths: map[string]string{ + "/calendar/days/2026-09-21.json": localDayJSON("day", "2026-09-21", "2026-09-21", zonedSupper, ""), + }} + stdout, _, err := runFormattedCommandWithStderr(t, server, []string{"--styled"}, "event", "day", "2026-09-20") + if err != nil || !strings.Contains(stdout, "2026-09-20T21:30") { + t.Errorf("event day = %s, %v, want the supper at 2026-09-20T21:30", stdout, err) + } +} + +func TestEventsDayInTokyoListsTheAllDayEventFirst(t *testing.T) { + inLocalZone(t, 0) + server := &localDayServer{zone: "Asia/Tokyo", paths: map[string]string{ + "/calendar/days/2026-09-19.json": localDayJSON("day", "2026-09-19", "2026-09-19", zonedBreakfast, ""), + "/calendar/days/2026-09-20.json": localDayJSON("day", "2026-09-20", "2026-09-20", zonedHoliday, ""), + }} + stdout, _, err := runFormattedCommandWithStderr(t, server, []string{"--styled"}, "event", "day", "2026-09-20", "--limit", "1") + if err != nil || !strings.Contains(stdout, "Company holiday") || strings.Contains(stdout, "Early breakfast") { + t.Errorf("event day = %s, %v, want only the all-day event", stdout, err) + } +} + +func TestEventsListPrintsTimesInTheAccountZone(t *testing.T) { + inLocalZone(t, 0) + server := &localDayServer{zone: "Asia/Tokyo", recordings: `{"Calendar::Event":[` + zonedBreakfast + `]}`} + stdout, _, err := runFormattedCommandWithStderr(t, server, []string{"--styled"}, "event", "list", "--calendar", "9102", "--starts-on", "2026-09-20", "--ends-on", "2026-09-20") + if err != nil || !strings.Contains(stdout, "2026-09-20T00:30") { + t.Errorf("event list = %s, %v, want the breakfast at 2026-09-20T00:30", stdout, err) + } +} + +func TestEventsDayInUTCReadsNoNeighbourDay(t *testing.T) { + server := &localDayServer{zone: "Etc/UTC"} + runLocalDay(t, server, "event", "day", "2026-09-20") + if reads := server.dayReads(); reads != 1 { + t.Errorf("day reads = %d, want 1", reads) + } +} + +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: "America/New_York", 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 090a10b1a..f0083b964 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,12 @@ own starts_at and ends_at and an occurrence_id, and nothing from 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. HEY reads a date as a UTC day, so the command also reads +the UTC day that the local day reaches into: the day after west of UTC, the day before east +of it, and neither in UTC. An event is listed when it overlaps the day. The table shows +times in that same zone, and --json keeps HEY's UTC times. + 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 — @@ -113,6 +120,11 @@ 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. HEY reads a date as a UTC day, so the +command also reads the UTC day that the week reaches into: the day after west of UTC, the +day before east of it, and neither in UTC. An event is listed when it overlaps the week. +The table shows times in that same zone, and --json keeps HEY's UTC times. + 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 — @@ -147,7 +159,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 } @@ -157,15 +170,18 @@ 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) + loc, err := account.locationToRead(ctx, cmd.ErrOrStderr()) + if err != nil { + return err } - sortEventsByStart(events) - rows := occurrenceEventRows(events) - if period != nil { - attachEventCountdowns(rows, filterRecordingsByType(&period.Recordings, recordingTypeCountdown)) + + events, countdowns, err := localPeriodRecordings(ctx, period, loc) + if err != nil { + return err } + sortEventsByStart(events, loc) + rows := occurrenceEventRows(events) + attachEventCountdowns(rows, countdowns) total := len(rows) if c.limit > 0 && !c.all && len(rows) > c.limit { @@ -173,13 +189,13 @@ func (c *eventsPeriodCommand) run(cmd *cobra.Command, args []string) error { } notice := output.TruncationNotice(len(rows), total) - return writeEventRows(cmd, rows, described, notice) + return writeEventRows(cmd, rows, described, notice, loc) } // 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 @@ -187,7 +203,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 @@ -196,6 +211,56 @@ 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, period *generated.CalendarPeriod, loc *time.Location) ([]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 + } + + 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, loc, 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 { @@ -287,9 +352,9 @@ func attachEventCountdowns(rows []eventRow, countdowns []generated.Recording) { // grouped by type with no promise about order, and instants alone would not do — an // all-day event is stamped midnight UTC, so a timed event late the same local evening // can be the earlier instant even though the app draws it below the band. -func sortEventsByStart(events []generated.Recording) { +func sortEventsByStart(events []generated.Recording, loc *time.Location) { sort.SliceStable(events, func(i, j int) bool { - if di, dj := eventDay(events[i]), eventDay(events[j]); di != dj { + if di, dj := eventDay(events[i], loc), eventDay(events[j], loc); di != dj { return di < dj } if events[i].AllDay != events[j].AllDay { @@ -303,19 +368,20 @@ func sortEventsByStart(events []generated.Recording) { } // eventDay names the day a row sits on, the day HEY draws it: the stamped date for an -// all-day event, the reader's own day for a timed one. HEY's JSON is always UTC, so a -// 23:30Z event belongs to the next day east of Greenwich. -func eventDay(event generated.Recording) string { +// all-day event, the day in the zone the days were picked in for a timed one. HEY's JSON is +// always UTC, so a 23:30Z event belongs to the next day east of Greenwich. +func eventDay(event generated.Recording, loc *time.Location) string { if event.AllDay { return event.StartsAt.UTC().Format(dateLayout) } - return event.StartsAt.Local().Format(dateLayout) + return event.StartsAt.In(loc).Format(dateLayout) } // writeEventRows renders one listing of events: the table when styled, the JSON envelope // with the add/edit/delete breadcrumbs otherwise. described names the span read, in -// whatever words the command reads it — a window, a day, a week. -func writeEventRows(cmd *cobra.Command, events []eventRow, described, notice string) error { +// whatever words the command reads it — a window, a day, a week. The table shows times in +// loc, the zone the days were picked in, while the JSON keeps HEY's UTC. +func writeEventRows(cmd *cobra.Command, events []eventRow, described, notice string, loc *time.Location) error { if writer.IsStyled() { if len(events) == 0 { fmt.Fprintf(cmd.OutOrStdout(), "No events %s.\n", described) @@ -360,8 +426,8 @@ func writeEventRows(cmd *cobra.Command, events []eventRow, described, notice str row = append(row, seriesID, event.OccurrenceId, recordingID) } row = append(row, event.Title, - eventBoundary(event.StartsAt, event.AllDay), - eventBoundary(event.EndsAt, event.AllDay), + eventBoundary(event.StartsAt, event.AllDay, loc), + eventBoundary(event.EndsAt, event.AllDay, loc), event.Calendar.Name) if showsCountdown { label := "" diff --git a/internal/cmd/events_period_test.go b/internal/cmd/events_period_test.go index 07da1c0ff..617dcab47 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) } @@ -69,7 +93,7 @@ func TestEventsPeriodShowsTheEventsCountdown(t *testing.T) { {"week", "/calendar/weeks/2026-09-28.json"}, } { t.Run(tt.name, func(t *testing.T) { - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := withLocalDays(nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != tt.path { t.Errorf("request = %s %s, want %s", r.Method, r.URL.Path, tt.path) http.NotFound(w, r) @@ -81,7 +105,7 @@ func TestEventsPeriodShowsTheEventsCountdown(t *testing.T) { `{"id":301,"title":"Calendar countdown check-in","starts_at":"2026-09-28T19:00:00Z","ends_at":"2026-09-28T19:30:00Z","type":"Calendar::Event","calendar":{"id":9,"name":"Personal"}},`+ `{"id":302,"title":"Project planning","starts_at":"2026-09-28T21:00:00Z","ends_at":"2026-09-28T21:30:00Z","type":"Calendar::Event","calendar":{"id":9,"name":"Personal"}}],`+ `"Calendar::Countdown":[{"id":303,"parent_id":301,"label":"2 days before","starts_at":"2026-09-26T00:00:00Z","ends_at":"2026-09-28T00:00:00Z","type":"Calendar::Countdown"}]}}`) - }) + })) response, err := runJSONCommand(t, handler, "event", tt.name, "2026-09-28") if err != nil { @@ -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) } @@ -314,7 +339,7 @@ func TestEventsPeriodStyledPublishesOccurrenceIdentifiers(t *testing.T) { {name: "week", period: "week", path: "/calendar/weeks/2026-09-15.json"}, } { t.Run(tt.name, func(t *testing.T) { - styled, err := runStyledCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + styled, err := runStyledCommand(t, withLocalDays(nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != tt.path { t.Errorf("request = %s %s, want %s", r.Method, r.URL.Path, tt.path) http.NotFound(w, r) @@ -324,7 +349,7 @@ func TestEventsPeriodStyledPublishesOccurrenceIdentifiers(t *testing.T) { _, _ = io.WriteString(w, `{"kind":"`+tt.period+`","recordings":{"Calendar::Event":[`+ `{"parent_id":204,"occurrence_id":"204_2026-09-15","title":"Standup","starts_at":"2026-09-15T09:15:00Z","ends_at":"2026-09-15T09:30:00Z","type":"Calendar::Event","calendar":{"id":9,"name":"Work"}},`+ `{"id":9001,"parent_id":4821,"occurrence_id":"4821_2026-09-13","title":"Design review","starts_at":"2026-09-15T13:30:00Z","ends_at":"2026-09-15T14:30:00Z","type":"Calendar::Event","calendar":{"id":9,"name":"Work"}}]}}`) - }), "event", tt.name, "2026-09-15") + })), "event", tt.name, "2026-09-15") if err != nil { t.Fatalf("execute event %s: %v", tt.name, 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) } @@ -406,10 +432,10 @@ func TestEventsDaySortsTheAllDayBandFirst(t *testing.T) { // all-day date is the day it names and does not shift. func TestEventBoundaryDrawsTheReadersClock(t *testing.T) { inLocalZone(t, 2) - if got := eventBoundary(time.Date(2026, 9, 2, 14, 0, 0, 0, time.UTC), false); got != "2026-09-02T16:00" { + if got := eventBoundary(time.Date(2026, 9, 2, 14, 0, 0, 0, time.UTC), false, time.Local); got != "2026-09-02T16:00" { t.Errorf("timed boundary = %q, want the reader's 16:00", got) } - if got := eventBoundary(time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC), true); got != "2026-09-02" { + if got := eventBoundary(time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC), true, time.Local); got != "2026-09-02" { t.Errorf("all-day boundary = %q, want the unshifted day", got) } } 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 60938ebf4..68c6c0e7b 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..4ed2256a0 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, loc, 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..242d1fb05 --- /dev/null +++ b/internal/tui/calendar_local_day_test.go @@ -0,0 +1,146 @@ +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 TestDayColumnsUseTheFirstInstantOfADayWithASkippedMidnight(t *testing.T) { + santiago, err := time.LoadLocation("America/Santiago") + if err != nil { + t.Skipf("time zone database unavailable: %v", err) + } + const daySpan = 96 + cases := []struct { + name string + starts string + ends string + day int + start, end int + }{ + {"event after the clocks jump", "2026-09-06T03:15:00Z", "2026-09-06T05:00:00Z", 6, 0, 8}, + {"event before the jump, on the day before", "2026-09-06T03:15:00Z", "2026-09-06T03:45:00Z", 5, 93, 95}, + } + for _, tc := range cases { + anchor := time.Date(2026, 9, tc.day, 12, 0, 0, 0, santiago) + if start, end := dayColumns(at(tc.starts).In(santiago), at(tc.ends).In(santiago), 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 TestDayColumnsEndTheDayBeforeASkippedMidnight(t *testing.T) { + santiago, err := time.LoadLocation("America/Santiago") + if err != nil { + t.Skipf("time zone database unavailable: %v", err) + } + const daySpan = 96 + anchor := time.Date(2026, 9, 5, 0, 30, 0, 0, santiago) + meeting := Recording{StartsAt: at("2026-09-05T14:00:00Z").In(santiago), EndsAt: at("2026-09-05T15:00:00Z").In(santiago)} + if start, end := dayColumns(meeting.StartsAt, meeting.EndsAt, anchor, daySpan); start != 40 || end != 44 { + t.Errorf("columns %d..%d, want 40..44", start, 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..ff05bb577 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,8 @@ 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 and the one UTC day west of UTC reaches into — +// two requests, not seven days' worth. func TestCalendarViewReadsTheWeekForTheWeekSpan(t *testing.T) { var mu sync.Mutex var paths []string @@ -495,14 +498,20 @@ func TestCalendarViewReadsTheWeekForTheWeekSpan(t *testing.T) { vc.sdk = hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "test-token"}, hey.WithMaxRetries(0)) v := newCalendarView(vc) v.Resize(80, 30) - v.now = func() time.Time { return time.Date(2025, 3, 9, 12, 0, 0, 0, time.UTC) } + zone, err := time.LoadLocation("America/New_York") + if err != nil { + t.Skipf("time zone database unavailable: %v", err) + } + v.now = func() time.Time { return time.Date(2025, 3, 9, 12, 0, 0, 0, zone) } v.viewMode = viewWeek 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-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..115c99af4 100644 --- a/internal/tui/calendar_views.go +++ b/internal/tui/calendar_views.go @@ -10,6 +10,7 @@ import ( "charm.land/lipgloss/v2" + "github.com/basecamp/hey-cli/internal/calendarday" habitvalues "github.com/basecamp/hey-cli/internal/habit" "github.com/basecamp/hey-cli/internal/terminal" ) @@ -698,11 +699,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 +770,25 @@ 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() + y, m, d := anchor.Date() + dayStart := calendarday.StartOfDay(anchor, loc) + dayEnd := calendarday.StartOfDay(time.Date(y, m, d+1, 0, 0, 0, 0, time.UTC), 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) } }