From 91e74841d6fd898ae0e8c0e9397f3150553e4446 Mon Sep 17 00:00:00 2001 From: Armaan Singh <10168472+armada877@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:05:50 -0400 Subject: [PATCH 1/6] Show a timed event on its local day, not its UTC day HEY reads a date in a JSON request as a UTC day. In New York, the day 2026-09-14 then runs from 8 PM on the 13th to 8 PM on the 14th. The TUI calendar and the event commands send only a date, so an event after 8 PM showed on the next day, and the TUI drew it there at its local hour. The week view dropped it, because it grouped the event on a day outside the UTC week. The day and week reads now also read the day before and the day after, and keep the timed events that overlap the local days. All-day events and the other recording types still come from the main read. The TUI uses the machine's zone, as it does for every time it draws. The CLI uses the HEY account's zone, as it does for today, and the machine's zone when the account has none. - hey tui: the day and week views show an evening event on its own day. The day view clips an event that crosses midnight to the hours of the day. - hey event day and hey event week: the same local days. - hey event list: --starts-on and --ends-on are local days, and --ends-on is included, as docs/cli.md already said. - hey event edit : finds an evening or early-morning event by the date it shows on. Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 8 + docs/tui.md | 3 + internal/calendarday/calendarday.go | 155 ++++++++++ internal/calendarday/calendarday_test.go | 249 +++++++++++++++ internal/cmd/account_zone.go | 23 +- internal/cmd/account_zone_test.go | 12 +- internal/cmd/calendar_commands_test.go | 32 +- internal/cmd/events.go | 63 +++- internal/cmd/events_local_day_test.go | 371 +++++++++++++++++++++++ internal/cmd/events_period.go | 73 ++++- internal/cmd/events_period_test.go | 36 ++- internal/cmd/events_test.go | 10 +- internal/tui/calendar.go | 8 +- internal/tui/calendar_local_day_test.go | 240 +++++++++++++++ internal/tui/calendar_test.go | 10 +- internal/tui/calendar_views.go | 21 +- internal/tui/habit_form_test.go | 9 +- 17 files changed, 1259 insertions(+), 64 deletions(-) create mode 100644 internal/calendarday/calendarday.go create mode 100644 internal/calendarday/calendarday_test.go create mode 100644 internal/cmd/events_local_day_test.go create mode 100644 internal/tui/calendar_local_day_test.go diff --git a/docs/cli.md b/docs/cli.md index 1bd714132..c0090c1d0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -460,6 +460,14 @@ 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`, `hey event week` and `hey event edit` 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. + `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..daf2cfc0b 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -214,6 +214,9 @@ 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. An event belongs to the +local day it overlaps, and the app reads the day either side of the span to find it. + 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..4e32da3f9 --- /dev/null +++ b/internal/calendarday/calendarday.go @@ -0,0 +1,155 @@ +package calendarday + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/basecamp/hey-sdk/go/pkg/generated" +) + +const ( + EventType = "Calendar::Event" + dateLayout = "2006-01-02" +) + +type Span struct { + First time.Time + Last time.Time +} + +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} +} + +func (s Span) Neighbours() (before, after string) { + return s.First.AddDate(0, 0, -1).Format(dateLayout), s.Last.AddDate(0, 0, 1).Format(dateLayout) +} + +func (s Span) Local(loc *time.Location) (from, to time.Time) { + return civilMidnight(s.First, loc), civilMidnight(s.Last.AddDate(0, 0, 1), loc) +} + +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) +} + +type DayReader func(ctx context.Context, date string) (*generated.CalendarPeriod, error) + +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 +} + +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 +} + +func InDays(r generated.Recording, first, last time.Time, loc *time.Location) bool { + if r.Recurring || r.StartsAt.IsZero() { + return true + } + 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)) +} + +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 +} + +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) +} + +func civilMidnight(day time.Time, loc *time.Location) time.Time { + return time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc) +} + +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) +} diff --git a/internal/calendarday/calendarday_test.go b/internal/calendarday/calendarday_test.go new file mode 100644 index 000000000..cc4b785b9 --- /dev/null +++ b/internal/calendarday/calendarday_test.go @@ -0,0 +1,249 @@ +package calendarday + +import ( + "testing" + "time" + + "github.com/basecamp/hey-sdk/go/pkg/generated" +) + +func mustLocation(t *testing.T, name string) *time.Location { + t.Helper() + loc, err := time.LoadLocation(name) + if err != nil { + t.Fatalf("load %s: %v", name, err) + } + return loc +} + +func instant(t *testing.T, value string) time.Time { + t.Helper() + at, err := time.Parse(time.RFC3339, value) + if err != nil { + t.Fatalf("parse %s: %v", value, err) + } + return at +} + +func utcDay(t *testing.T, date string) *generated.CalendarPeriod { + t.Helper() + start := instant(t, date+"T00:00:00Z") + return &generated.CalendarPeriod{ + Kind: "day", + StartsAt: start, + EndsAt: instant(t, date+"T23:59:59Z"), + Recordings: generated.CalendarRecordingsResponse{}, + } +} + +func withEvents(period *generated.CalendarPeriod, events ...generated.Recording) *generated.CalendarPeriod { + period.Recordings[EventType] = append(period.Recordings[EventType], events...) + return period +} + +func titles(events []generated.Recording) []string { + names := make([]string, 0, len(events)) + for _, event := range events { + names = append(names, event.Title) + } + return names +} + +func hasTitle(events []generated.Recording, title string) bool { + for _, event := range events { + if event.Title == title { + return true + } + } + return false +} + +func TestSpanOfDayEndingAtLastSecond(t *testing.T) { + span := SpanOf(utcDay(t, "2026-09-20")) + if got := span.First.Format(dateLayout); got != "2026-09-20" { + t.Errorf("First = %s", got) + } + if got := span.Last.Format(dateLayout); got != "2026-09-20" { + t.Errorf("Last = %s", got) + } +} + +func TestSpanOfWeek(t *testing.T) { + week := &generated.CalendarPeriod{ + Kind: "week", + StartsAt: instant(t, "2026-09-14T00:00:00Z"), + EndsAt: instant(t, "2026-09-20T23:59:59Z"), + } + span := SpanOf(week) + before, after := span.Neighbours() + if before != "2026-09-13" || after != "2026-09-21" { + t.Errorf("Neighbours = %s, %s", before, after) + } +} + +func TestSpanOfEndAtMidnightExcludesThatDay(t *testing.T) { + period := &generated.CalendarPeriod{ + StartsAt: instant(t, "2026-09-14T00:00:00Z"), + EndsAt: instant(t, "2026-09-21T00:00:00Z"), + } + if got := SpanOf(period).Last.Format(dateLayout); got != "2026-09-20" { + t.Errorf("Last = %s", got) + } +} + +func TestSpanLocalFollowsClockChange(t *testing.T) { + newYork := mustLocation(t, "America/New_York") + from, to := SpanOf(utcDay(t, "2026-11-01")).Local(newYork) + if got := to.Sub(from); got != 25*time.Hour { + t.Errorf("local day length = %s, want 25h", got) + } +} + +func TestOverlapsInstantEvent(t *testing.T) { + from := instant(t, "2026-09-20T04:00:00Z") + to := instant(t, "2026-09-21T04:00:00Z") + event := generated.Recording{StartsAt: to} + if Overlaps(event, from, to) { + t.Error("an instant at the end of the span must not overlap") + } + event.StartsAt = from + if !Overlaps(event, from, to) { + t.Error("an instant at the start of the span must overlap") + } +} + +func TestOverlapsEventEndingAtStartOfSpan(t *testing.T) { + from := instant(t, "2026-09-19T04:00:00Z") + to := instant(t, "2026-09-20T04:00:00Z") + event := generated.Recording{ + StartsAt: instant(t, "2026-09-18T22:00:00Z"), + EndsAt: from, + } + if Overlaps(event, from, to) { + t.Error("an event that ends when the span starts must not overlap") + } +} + +func TestLocalEventsByZone(t *testing.T) { + dinner := generated.Recording{Id: 4821, Title: "Late dinner with Priya Patel", StartsAt: instant(t, "2026-09-21T00:50:00Z"), EndsAt: instant(t, "2026-09-21T03:00:00Z")} + concert := generated.Recording{Id: 4822, Title: "Evening concert", StartsAt: instant(t, "2026-09-18T22:00:00Z"), EndsAt: instant(t, "2026-09-19T01:30:00Z")} + breakfast := generated.Recording{Id: 5, Title: "Breakfast", StartsAt: instant(t, "2026-09-20T13:00:00Z"), EndsAt: instant(t, "2026-09-20T14:00:00Z")} + + cases := []struct { + name string + zone string + date string + main []generated.Recording + before []generated.Recording + after []generated.Recording + want []string + wantNo []string + }{ + {name: "dinner is on the 20th in New York", zone: "America/New_York", date: "2026-09-20", main: []generated.Recording{breakfast}, after: []generated.Recording{dinner}, want: []string{"Breakfast", "Late dinner with Priya Patel"}}, + {name: "dinner is not on the 21st in New York", zone: "America/New_York", date: "2026-09-21", main: []generated.Recording{dinner}, wantNo: []string{"Late dinner with Priya Patel"}}, + {name: "concert is not on the 19th in New York", zone: "America/New_York", date: "2026-09-19", main: []generated.Recording{concert}, wantNo: []string{"Evening concert"}}, + {name: "concert is on the 18th in New York", zone: "America/New_York", date: "2026-09-18", main: []generated.Recording{concert}, want: []string{"Evening concert"}}, + {name: "dinner is on the 21st in Tokyo", zone: "Asia/Tokyo", date: "2026-09-21", main: []generated.Recording{dinner}, want: []string{"Late dinner with Priya Patel"}}, + {name: "dinner is not on the 20th in Tokyo", zone: "Asia/Tokyo", date: "2026-09-20", after: []generated.Recording{dinner}, wantNo: []string{"Late dinner with Priya Patel"}}, + {name: "dinner is on the 21st in UTC", zone: "UTC", date: "2026-09-21", main: []generated.Recording{dinner}, want: []string{"Late dinner with Priya Patel"}}, + {name: "concert from before-day is on the 19th in UTC", zone: "UTC", date: "2026-09-19", main: []generated.Recording{concert}, want: []string{"Evening concert"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + main := withEvents(utcDay(t, tc.date), tc.main...) + neighbours := []*generated.CalendarPeriod{ + withEvents(utcDay(t, "2026-09-01"), tc.before...), + withEvents(utcDay(t, "2026-09-01"), tc.after...), + } + got := LocalEvents(main, neighbours, mustLocation(t, tc.zone)) + for _, title := range tc.want { + if !hasTitle(got, title) { + t.Errorf("missing %q in %v", title, titles(got)) + } + } + for _, title := range tc.wantNo { + if hasTitle(got, title) { + t.Errorf("unexpected %q in %v", title, titles(got)) + } + } + }) + } +} + +func TestLocalEventsDayOfClockChange(t *testing.T) { + newYork := mustLocation(t, "America/New_York") + lateNight := generated.Recording{Id: 1, Title: "Late show", StartsAt: instant(t, "2026-11-02T04:30:00Z"), EndsAt: instant(t, "2026-11-02T04:45:00Z")} + nextDay := generated.Recording{Id: 2, Title: "Early start", StartsAt: instant(t, "2026-11-02T05:00:00Z"), EndsAt: instant(t, "2026-11-02T06:00:00Z")} + main := utcDay(t, "2026-11-01") + after := withEvents(utcDay(t, "2026-11-02"), lateNight, nextDay) + + got := LocalEvents(main, []*generated.CalendarPeriod{after}, newYork) + if !hasTitle(got, "Late show") || hasTitle(got, "Early start") { + t.Errorf("got %v", titles(got)) + } +} + +func TestLocalEventsKeepAllDayOfMainOnly(t *testing.T) { + holiday := generated.Recording{Id: 9, Title: "Holiday", AllDay: true, StartsAt: instant(t, "2026-09-20T00:00:00Z"), EndsAt: instant(t, "2026-09-20T00:00:00Z")} + otherHoliday := generated.Recording{Id: 10, Title: "Other holiday", AllDay: true, StartsAt: instant(t, "2026-09-21T00:00:00Z"), EndsAt: instant(t, "2026-09-21T00:00:00Z")} + main := withEvents(utcDay(t, "2026-09-20"), holiday) + after := withEvents(utcDay(t, "2026-09-21"), otherHoliday) + + got := LocalEvents(main, []*generated.CalendarPeriod{after}, mustLocation(t, "America/New_York")) + if !hasTitle(got, "Holiday") || hasTitle(got, "Other holiday") { + t.Errorf("got %v", titles(got)) + } +} + +func TestLocalEventsDeduplicate(t *testing.T) { + overnight := generated.Recording{Id: 3, Title: "Night flight", StartsAt: instant(t, "2026-09-20T23:00:00Z"), EndsAt: instant(t, "2026-09-21T05:00:00Z")} + main := withEvents(utcDay(t, "2026-09-20"), overnight) + after := withEvents(utcDay(t, "2026-09-21"), overnight) + + got := LocalEvents(main, []*generated.CalendarPeriod{after}, mustLocation(t, "UTC")) + if len(got) != 1 { + t.Errorf("got %d events, want 1", len(got)) + } +} + +func TestLocalEventsDeduplicateOccurrencesWithoutId(t *testing.T) { + first := generated.Recording{OccurrenceId: "7_2026-09-20", Title: "Standup", StartsAt: instant(t, "2026-09-20T14:00:00Z")} + second := generated.Recording{OccurrenceId: "7_2026-09-21", Title: "Standup", StartsAt: instant(t, "2026-09-21T14:00:00Z")} + main := withEvents(utcDay(t, "2026-09-21"), second) + before := withEvents(utcDay(t, "2026-09-20"), first, second) + + got := LocalEvents(main, []*generated.CalendarPeriod{before}, mustLocation(t, "UTC")) + if len(got) != 1 { + t.Errorf("got %d events, want 1", len(got)) + } +} + +func TestInDays(t *testing.T) { + newYork := mustLocation(t, "America/New_York") + day := func(date string) time.Time { return instant(t, date+"T00:00:00Z") } + cases := []struct { + name string + event generated.Recording + first string + last string + want bool + }{ + {"evening dinner is on its local day", generated.Recording{StartsAt: instant(t, "2026-09-21T00:50:00Z"), EndsAt: instant(t, "2026-09-21T03:00:00Z")}, "2026-09-20", "2026-09-20", true}, + {"evening dinner is not on the next day", generated.Recording{StartsAt: instant(t, "2026-09-21T00:50:00Z"), EndsAt: instant(t, "2026-09-21T03:00:00Z")}, "2026-09-21", "2026-09-21", false}, + {"concert ended before the day", generated.Recording{StartsAt: instant(t, "2026-09-18T22:00:00Z"), EndsAt: instant(t, "2026-09-19T01:30:00Z")}, "2026-09-19", "2026-09-19", false}, + {"concert is on the 18th", generated.Recording{StartsAt: instant(t, "2026-09-18T22:00:00Z"), EndsAt: instant(t, "2026-09-19T01:30:00Z")}, "2026-09-18", "2026-09-18", true}, + {"series row is always kept", generated.Recording{Recurring: true, StartsAt: instant(t, "2026-01-05T14:00:00Z")}, "2026-09-20", "2026-09-20", true}, + {"event without a start is kept", generated.Recording{}, "2026-09-20", "2026-09-20", true}, + {"all-day event 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 event outside", generated.Recording{AllDay: true, StartsAt: day("2026-09-20"), EndsAt: day("2026-09-20")}, "2026-09-21", "2026-09-22", false}, + {"multi-day all-day event overlaps", 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 { + t.Run(tc.name, func(t *testing.T) { + if got := InDays(tc.event, day(tc.first), day(tc.last), newYork); got != tc.want { + t.Errorf("InDays = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/cmd/account_zone.go b/internal/cmd/account_zone.go index 1346b74bf..76f8b8c51 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,21 @@ func (a *accountZone) todayToRead(ctx context.Context, stderr io.Writer, hint st return calendarDay(clockNow().In(loc)), nil } +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..908d035a4 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, 32).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..c405510f0 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-05" { + t.Errorf("ends_on = %q, want 30 days after 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..e0278bcd2 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.`, 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,37 @@ func (c *eventsListCommand) run(cmd *cobra.Command, args []string) error { return writeEventRows(cmd, eventRows(events), window.describe(), notice) } +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 +312,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 on that day. +The read covers the day before and the day after it too, so an evening or an early morning +event is found by the date you see it 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 +594,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 +626,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..4b1fa9730 --- /dev/null +++ b/internal/cmd/events_local_day_test.go @@ -0,0 +1,371 @@ +package cmd + +import ( + "io" + "net/http" + "strings" + "testing" + "time" +) + +const ( + localDayDinner = `{"id":4821,"title":"Late dinner with Priya Patel","starts_at":"2026-09-21T00:50:00Z","ends_at":"2026-09-21T03:00:00Z","type":"Calendar::Event","calendar":{"id":9102,"name":"Personal"}}` + localDayConcert = `{"id":4822,"title":"Evening concert","starts_at":"2026-09-18T22:00:00Z","ends_at":"2026-09-19T01:30:00Z","type":"Calendar::Event","calendar":{"id":9102,"name":"Personal"}}` + 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","calendar":{"id":9102,"name":"Personal"}}` +) + +func localDayJSON(date string, events ...string) string { + return localDayJSONWith(date, strings.Join(events, ","), "") +} + +func localDayJSONWith(date, events, countdowns string) string { + body := `{"kind":"day","starts_at":"` + date + `T00:00:00Z","ends_at":"` + date + `T23:59:59Z","recordings":{"Calendar::Event":[` + events + `]` + if countdowns != "" { + body += `,"Calendar::Countdown":[` + countdowns + `]` + } + return body + `}}` +} + +func localWeekJSON(first, last string, events ...string) string { + return `{"kind":"week","starts_at":"` + first + `T00:00:00Z","ends_at":"` + last + `T23:59:59Z","recordings":{"Calendar::Event":[` + strings.Join(events, ",") + `]}}` +} + +type localDayServer struct { + zone string + identityStatus int + paths map[string]string + identityReads int + requestedPaths []string + listedRecording string +} + +func (s *localDayServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + s.requestedPaths = append(s.requestedPaths, r.URL.Path+"?"+r.URL.RawQuery) + switch r.URL.Path { + case "/identity.json": + s.identityReads++ + if s.identityStatus != 0 { + http.Error(w, `{"error":"identity unavailable"}`, s.identityStatus) + return + } + zone := "null" + if s.zone != "" { + zone = `"` + s.zone + `"` + } + _, _ = io.WriteString(w, `{"id":1,"name":"Jason Fried","time_zone":`+zone+`}`) + case "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9102,"name":"Personal","owned":true,"personal":true}}]}`) + case "/calendars/9102/recordings.json": + _, _ = io.WriteString(w, s.listedRecording) + default: + if body, ok := s.paths[r.URL.Path]; ok { + _, _ = io.WriteString(w, body) + return + } + if date, ok := strings.CutPrefix(r.URL.Path, "/calendar/days/"); ok { + _, _ = io.WriteString(w, localDayJSON(strings.TrimSuffix(date, ".json"))) + return + } + http.NotFound(w, r) + } +} + +func eventIDsOf(t *testing.T, data any) []float64 { + t.Helper() + rows, ok := data.([]any) + if !ok { + t.Fatalf("data = %#v, want a list", data) + } + ids := make([]float64, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.(map[string]any)["id"].(float64)) + } + return ids +} + +func containsID(ids []float64, id float64) bool { + for _, candidate := range ids { + if candidate == id { + return true + } + } + return false +} + +func TestEventsDayKeepsTheEventsOfTheAccountsLocalDay(t *testing.T) { + paths := map[string]string{ + "/calendar/days/2026-09-19.json": localDayJSON("2026-09-19", localDayConcert), + "/calendar/days/2026-09-20.json": localDayJSON("2026-09-20", localDayBreakfast), + "/calendar/days/2026-09-21.json": localDayJSON("2026-09-21", localDayDinner), + } + cases := []struct { + date string + want []float64 + wantNot []float64 + }{ + {date: "2026-09-20", want: []float64{4821, 4824}}, + {date: "2026-09-21", wantNot: []float64{4821}}, + {date: "2026-09-19", wantNot: []float64{4822}}, + {date: "2026-09-18", want: []float64{4822}}, + } + for _, tc := range cases { + t.Run(tc.date, func(t *testing.T) { + server := &localDayServer{zone: "America/New_York", paths: paths} + response, err := runJSONCommand(t, server, "event", "day", tc.date) + if err != nil { + t.Fatalf("event day %s: %v", tc.date, err) + } + ids := eventIDsOf(t, response.Data) + for _, id := range tc.want { + if !containsID(ids, id) { + t.Errorf("event %.0f missing from %v", id, ids) + } + } + for _, id := range tc.wantNot { + if containsID(ids, id) { + t.Errorf("event %.0f must not be on %s: %v", id, tc.date, ids) + } + } + }) + } +} + +func TestEventsDayCutsDaysInTheAccountZoneNotTheMachines(t *testing.T) { + inLocalZone(t, 9) + server := &localDayServer{zone: "America/New_York", paths: map[string]string{ + "/calendar/days/2026-09-21.json": localDayJSON("2026-09-21", localDayDinner), + }} + response, err := runJSONCommand(t, server, "event", "day", "2026-09-20") + if err != nil { + t.Fatalf("event day: %v", err) + } + if !containsID(eventIDsOf(t, response.Data), 4821) { + t.Errorf("the dinner is on the account's 2026-09-20: %#v", response.Data) + } +} + +func TestEventsWeekPlacesTheEveningDinnerInTheLocalWeek(t *testing.T) { + server := &localDayServer{zone: "America/New_York", paths: map[string]string{ + "/calendar/weeks/2026-09-14.json": localWeekJSON("2026-09-14", "2026-09-20", localDayBreakfast), + "/calendar/days/2026-09-21.json": localDayJSON("2026-09-21", localDayDinner), + }} + response, err := runJSONCommand(t, server, "event", "week", "2026-09-14") + if err != nil { + t.Fatalf("event week: %v", err) + } + if ids := eventIDsOf(t, response.Data); !containsID(ids, 4821) || !containsID(ids, 4824) { + t.Errorf("week ids = %v, want the dinner and breakfast", ids) + } +} + +func TestEventsWeekDropsAnEventThatFallsInTheNextLocalWeek(t *testing.T) { + lateSunday := `{"id":4830,"title":"Sunday night film","starts_at":"2026-09-21T03:30:00Z","ends_at":"2026-09-21T05:30:00Z","type":"Calendar::Event"}` + nextMonday := `{"id":4831,"title":"Monday standup","starts_at":"2026-09-21T14:00:00Z","ends_at":"2026-09-21T14:30:00Z","type":"Calendar::Event"}` + server := &localDayServer{zone: "America/New_York", paths: map[string]string{ + "/calendar/weeks/2026-09-14.json": localWeekJSON("2026-09-14", "2026-09-20"), + "/calendar/days/2026-09-21.json": localDayJSON("2026-09-21", lateSunday, nextMonday), + }} + response, err := runJSONCommand(t, server, "event", "week", "2026-09-14") + if err != nil { + t.Fatalf("event week: %v", err) + } + ids := eventIDsOf(t, response.Data) + if !containsID(ids, 4830) || containsID(ids, 4831) { + t.Errorf("week ids = %v, want the Sunday film and not Monday's standup", ids) + } +} + +func TestEventsDayKeepsTheCountdownOfAnEventFromTheNeighbouringDay(t *testing.T) { + countdown := `{"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"}` + server := &localDayServer{zone: "America/New_York", paths: map[string]string{ + "/calendar/days/2026-09-21.json": localDayJSONWith("2026-09-21", localDayDinner, countdown), + }} + response, err := runJSONCommand(t, server, "event", "day", "2026-09-20") + if err != nil { + t.Fatalf("event day: %v", err) + } + rows := response.Data.([]any) + if len(rows) != 1 { + t.Fatalf("rows = %#v, want the dinner", rows) + } + labelled, ok := rows[0].(map[string]any)["countdown"].(map[string]any) + if !ok || labelled["label"] != "1 day before" { + t.Errorf("countdown = %#v, want the neighbour's countdown", rows[0]) + } +} + +func TestEventsDayWithoutAnAccountZoneUsesTheMachinesDaysAndSaysSoOnce(t *testing.T) { + inLocalZone(t, -4) + previousClock := clockNow + clockNow = func() time.Time { return time.Date(2026, 9, 20, 15, 0, 0, 0, time.UTC) } + t.Cleanup(func() { clockNow = previousClock }) + + server := &localDayServer{paths: map[string]string{ + "/calendar/days/2026-09-21.json": localDayJSON("2026-09-21", localDayDinner), + }} + stdout, stderr, err := runFormattedCommandWithStderr(t, server, []string{"--json"}, "event", "day") + if err != nil { + t.Fatalf("event day: %v", err) + } + if !strings.Contains(stdout, "4821") { + t.Errorf("stdout = %s, want the dinner", stdout) + } + if got := strings.Count(stderr, "Notice:"); got != 1 { + t.Errorf("stderr has %d notices, want one: %q", got, stderr) + } + if server.identityReads != 1 { + t.Errorf("identity read %d times, want once", server.identityReads) + } +} + +func TestEventsDayWithADateAndNoAccountZoneSaysSo(t *testing.T) { + inLocalZone(t, -4) + server := &localDayServer{paths: map[string]string{ + "/calendar/days/2026-09-21.json": localDayJSON("2026-09-21", localDayDinner), + }} + stdout, stderr, err := runFormattedCommandWithStderr(t, server, []string{"--json"}, "event", "day", "2026-09-20") + if err != nil { + t.Fatalf("event day: %v", err) + } + if !strings.Contains(stdout, "4821") { + t.Errorf("stdout = %s, want the dinner", stdout) + } + if !strings.Contains(stderr, "so days are this machine's days") || strings.Count(stderr, "Notice:") != 1 { + t.Errorf("stderr = %q, want one notice", stderr) + } +} + +func TestEventsDayRefusesWhenTheIdentityCannotBeRead(t *testing.T) { + server := &localDayServer{identityStatus: http.StatusInternalServerError} + if _, err := runJSONCommand(t, server, "event", "day", "2026-09-20"); err == nil { + t.Error("an unreadable identity must fail the read") + } +} + +func TestEventsDayFailsWhenANeighbouringDayCannotBeRead(t *testing.T) { + server := &localDayServer{zone: "UTC"} + failing := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/calendar/days/2026-09-21.json" { + http.Error(w, `{"error":"unavailable"}`, http.StatusInternalServerError) + return + } + server.ServeHTTP(w, r) + }) + if _, err := runJSONCommand(t, failing, "event", "day", "2026-09-20"); err == nil { + t.Error("a failed neighbouring read must fail the day") + } +} + +const ( + localDaySeries = `{"id":204,"title":"Weekly planning","recurring":true,"starts_at":"2026-01-05T14:00:00Z","ends_at":"2026-01-05T15:00:00Z","type":"Calendar::Event"}` + localDayAllDay = `{"id":4825,"title":"Maria Lopez's birthday","all_day":true,"starts_at":"2026-09-20T00:00:00Z","ends_at":"2026-09-20T00:00:00Z","type":"Calendar::Event"}` + localDayNextAllDay = `{"id":4826,"title":"Daylight saving ends","all_day":true,"starts_at":"2026-11-01T00:00:00Z","ends_at":"2026-11-01T00:00:00Z","type":"Calendar::Event"}` + localDayRecordings = `{"Calendar::Event":[` + localDayDinner + `,` + localDayConcert + `,` + localDayBreakfast + `,` + localDaySeries + `,` + localDayAllDay + `,` + localDayNextAllDay + `]}` +) + +func TestEventsListKeepsTheEventsOfTheLocalDays(t *testing.T) { + cases := []struct { + name string + args []string + want []float64 + wantNot []float64 + }{ + { + name: "one day", + args: []string{"--starts-on", "2026-09-20", "--ends-on", "2026-09-20"}, + want: []float64{4821, 4824, 204, 4825}, + wantNot: []float64{4822, 4826}, + }, + { + name: "the day after leaves the evening dinner behind", + args: []string{"--starts-on", "2026-09-21", "--ends-on", "2026-09-21"}, + want: []float64{204}, + wantNot: []float64{4821, 4822, 4825}, + }, + { + name: "ends-on is inclusive", + args: []string{"--starts-on", "2026-09-19", "--ends-on", "2026-09-20"}, + want: []float64{4821, 4824, 4825}, + wantNot: []float64{4822, 4826}, + }, + { + name: "the concert is on the day it started", + args: []string{"--starts-on", "2026-09-18", "--ends-on", "2026-09-18"}, + want: []float64{4822}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server := &localDayServer{zone: "America/New_York", listedRecording: localDayRecordings} + args := append([]string{"event", "list", "--calendar", "9102"}, tc.args...) + response, err := runJSONCommand(t, server, args...) + if err != nil { + t.Fatalf("event list: %v", err) + } + ids := eventIDsOf(t, response.Data) + for _, id := range tc.want { + if !containsID(ids, id) { + t.Errorf("event %.0f missing from %v", id, ids) + } + } + for _, id := range tc.wantNot { + if containsID(ids, id) { + t.Errorf("event %.0f must not be listed: %v", id, ids) + } + } + }) + } +} + +func TestEventsListSummaryNamesTheDatesGiven(t *testing.T) { + server := &localDayServer{zone: "America/New_York", listedRecording: localDayRecordings} + response, err := runJSONCommand(t, server, "event", "list", "--calendar", "9102", "--starts-on", "2026-09-20", "--ends-on", "2026-09-20") + if err != nil { + t.Fatalf("event list: %v", err) + } + if !strings.Contains(response.Summary, "(2026-09-20 to 2026-09-20)") { + t.Errorf("summary = %q, want the dates the reader gave", response.Summary) + } + wanted := "starts_on=2026-09-19&ends_on=2026-09-22" + found := false + for _, path := range server.requestedPaths { + if strings.Contains(path, "/calendars/9102/recordings.json") && strings.Contains(path, "ends_on=2026-09-22") && strings.Contains(path, "starts_on=2026-09-19") { + found = true + } + } + if !found { + t.Errorf("requests = %v, want a read with %s", server.requestedPaths, wanted) + } +} + +func TestEventsEditFindsAnEveningEventByItsLocalDate(t *testing.T) { + server := &localDayServer{zone: "America/New_York"} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/calendars/9102/recordings.json" { + endsOn, err := time.Parse(dateLayout, r.URL.Query().Get("ends_on")) + if err != nil { + t.Errorf("ends_on = %q", r.URL.Query().Get("ends_on")) + } + w.Header().Set("Content-Type", "application/json") + dinnerStart := time.Date(2026, 9, 21, 0, 50, 0, 0, time.UTC) + if dinnerStart.After(endsOn) { + _, _ = io.WriteString(w, `{"Calendar::Event":[]}`) + return + } + _, _ = io.WriteString(w, `{"Calendar::Event":[`+localDayDinner+`]}`) + return + } + if r.Method == http.MethodPatch { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":4821,"title":"Late dinner with Priya Patel and Tom"}`) + return + } + server.ServeHTTP(w, r) + }) + + _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-09-20", "--title", "Late dinner with Priya Patel and Tom") + if err != nil { + t.Fatalf("event edit by the local date: %v", err) + } +} diff --git a/internal/cmd/events_period.go b/internal/cmd/events_period.go index bfca14f5f..1a5cdb7c1 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,55 @@ func (c *eventsPeriodCommand) date(ctx context.Context, cmd *cobra.Command, args return date, c.describe(date, true), nil } +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 +} + +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/tui/calendar.go b/internal/tui/calendar.go index 5ea405c69..a8bde1cbb 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" ) @@ -2560,7 +2561,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, time.Local) + 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..0b94339eb --- /dev/null +++ b/internal/tui/calendar_local_day_test.go @@ -0,0 +1,240 @@ +package tui + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + hey "github.com/basecamp/hey-sdk/go/pkg/hey" +) + +const ( + dinnerJSON = `{"id":4821,"title":"Late dinner with Priya Patel","type":"CalendarEvent","starts_at":"2026-09-21T00:50:00Z","ends_at":"2026-09-21T03:00:00Z"}` + concertJSON = `{"id":4822,"title":"Evening concert","type":"CalendarEvent","starts_at":"2026-09-18T22:00:00Z","ends_at":"2026-09-19T01:30:00Z"}` + breakfastJSON = `{"id":4824,"title":"Breakfast with Maria Lopez","type":"CalendarEvent","starts_at":"2026-09-20T13:00:00Z","ends_at":"2026-09-20T14:00:00Z"}` +) + +func inNewYork(t *testing.T) { + t.Helper() + newYork, err := time.LoadLocation("America/New_York") + if err != nil { + t.Skipf("time zone database unavailable: %v", err) + } + previous := time.Local + time.Local = newYork + t.Cleanup(func() { time.Local = previous }) +} + +func dayJSON(date string, events ...string) string { + return fmt.Sprintf(`{"starts_at":"%sT00:00:00Z","ends_at":"%sT23:59:59Z","kind":"day","recordings":{"Calendar::Event":[%s]}}`, + date, date, strings.Join(events, ",")) +} + +func weekJSON(first, last string, events ...string) string { + return fmt.Sprintf(`{"starts_at":"%sT00:00:00Z","ends_at":"%sT23:59:59Z","kind":"week","recordings":{"Calendar::Event":[%s]}}`, + first, last, strings.Join(events, ",")) +} + +func localDayView(t *testing.T, mode calendarViewMode, day time.Time, answers map[string]string) *calendarView { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + if body, ok := answers[req.URL.Path]; ok { + _, _ = w.Write([]byte(body)) + return + } + date := strings.TrimSuffix(strings.TrimPrefix(req.URL.Path, "/calendar/days/"), ".json") + _, _ = w.Write([]byte(dayJSON(date))) + })) + t.Cleanup(server.Close) + + vc := testVC() + 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 day } + v.viewMode = mode + return v +} + +func readTitles(t *testing.T, v *calendarView) []string { + t.Helper() + msg, ok := v.requestRecordings()().(recordingsLoadedMsg) + if !ok { + t.Fatal("read did not answer recordings") + } + if msg.err != nil { + t.Fatalf("read failed: %v", msg.err) + } + titles := make([]string, 0, len(msg.recordings)) + for _, recording := range msg.recordings { + titles = append(titles, recording.Title) + } + return titles +} + +func hasTitle(titles []string, prefix string) bool { + for _, title := range titles { + if strings.HasPrefix(title, prefix) { + return true + } + } + return false +} + +func TestCalendarDayViewShowsTheEveningDinnerOnItsLocalDay(t *testing.T) { + inNewYork(t) + answers := map[string]string{ + "/calendar/days/2026-09-20.json": dayJSON("2026-09-20", breakfastJSON), + "/calendar/days/2026-09-21.json": dayJSON("2026-09-21", dinnerJSON), + } + day := time.Date(2026, 9, 20, 12, 0, 0, 0, time.Local) + titles := readTitles(t, localDayView(t, viewDay, day, answers)) + + if !hasTitle(titles, "Late dinner with Priya Patel") || !hasTitle(titles, "Breakfast") { + t.Errorf("titles of 2026-09-20 = %v, want the dinner and breakfast", titles) + } +} + +func TestCalendarDayViewDropsTheEveningDinnerFromTheNextLocalDay(t *testing.T) { + inNewYork(t) + answers := map[string]string{ + "/calendar/days/2026-09-21.json": dayJSON("2026-09-21", dinnerJSON), + } + day := time.Date(2026, 9, 21, 12, 0, 0, 0, time.Local) + titles := readTitles(t, localDayView(t, viewDay, day, answers)) + + if hasTitle(titles, "Late dinner with Priya Patel") { + t.Errorf("titles of 2026-09-21 = %v, want no dinner", titles) + } +} + +func TestCalendarDayViewDropsTheBusThatEndedTheDayBefore(t *testing.T) { + inNewYork(t) + answers := map[string]string{ + "/calendar/days/2026-09-19.json": dayJSON("2026-09-19", concertJSON), + "/calendar/days/2026-09-18.json": dayJSON("2026-09-18", concertJSON), + } + day := time.Date(2026, 9, 19, 12, 0, 0, 0, time.Local) + titles := readTitles(t, localDayView(t, viewDay, day, answers)) + + if hasTitle(titles, "Evening concert") { + t.Errorf("titles of 2026-09-19 = %v, want no concert", titles) + } +} + +func TestCalendarWeekViewPlacesTheEveningDinnerOnSunday(t *testing.T) { + inNewYork(t) + answers := map[string]string{ + "/calendar/weeks/2026-09-17.json": weekJSON("2026-09-14", "2026-09-20", breakfastJSON), + "/calendar/days/2026-09-21.json": dayJSON("2026-09-21", dinnerJSON), + } + day := time.Date(2026, 9, 17, 12, 0, 0, 0, time.Local) + v := localDayView(t, viewWeek, day, answers) + msg := v.requestRecordings()().(recordingsLoadedMsg) + if msg.err != nil { + t.Fatalf("read failed: %v", msg.err) + } + + byDate := eventsByDate(msg.recordings) + sunday := byDate["2026-09-20"] + foundDinner := false + for _, event := range sunday { + if strings.HasPrefix(event.Title, "Late dinner with Priya Patel") { + foundDinner = true + } + } + if !foundDinner { + t.Errorf("events on 2026-09-20 = %v, want the dinner", sunday) + } + if len(byDate["2026-09-21"]) != 0 { + t.Errorf("events on 2026-09-21 = %v, want none", byDate["2026-09-21"]) + } +} + +func TestCalendarReadFailsWhenANeighbourDayFails(t *testing.T) { + inNewYork(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + if req.URL.Path == "/calendar/days/2026-09-21.json" { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"neighbour failed"}`)) + return + } + _, _ = w.Write([]byte(dayJSON("2026-09-20", breakfastJSON))) + })) + t.Cleanup(server.Close) + + vc := testVC() + 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(2026, 9, 20, 12, 0, 0, 0, time.Local) } + + msg := v.requestRecordings()().(recordingsLoadedMsg) + if msg.err == nil { + t.Error("a failed neighbour read must fail the whole read") + } +} + +func TestDayColumnsClipASpanningEventToTheDay(t *testing.T) { + inNewYork(t) + const daySpan = 96 + dinner := Recording{StartsAt: at("2026-09-21T00:50:00Z"), EndsAt: at("2026-09-21T04:20:00Z")} + hotel := Recording{StartsAt: at("2026-09-16T18:00:00Z"), EndsAt: at("2026-09-18T15:00:00Z")} + anchorOn := func(day int) time.Time { return time.Date(2026, 9, day, 12, 0, 0, 0, time.Local) } + + cases := []struct { + name string + event Recording + day int + wantStartPos int + wantEndPos int + }{ + {"dinner on its evening", dinner, 20, 20*60 + 50, daySpan}, + {"dinner after midnight", dinner, 21, 0, 20}, + {"hotel fills a middle day", hotel, 17, 0, daySpan}, + {"hotel on its last day", hotel, 18, 0, 11 * 60}, + {"hotel on its first day", hotel, 16, 14 * 60, daySpan}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + start, end := dayColumns(tc.event.Starts(), tc.event.Ends(), anchorOn(tc.day), daySpan) + wantStart := tc.wantStartPos * daySpan / (24 * 60) + wantEnd := tc.wantEndPos * daySpan / (24 * 60) + if tc.wantEndPos == daySpan { + wantEnd = daySpan + } + if start != wantStart || end != wantEnd { + t.Errorf("columns = %d..%d, want %d..%d", start, end, wantStart, wantEnd) + } + }) + } +} + +func TestDayViewDrawsASpanningEventAcrossTheHoursOfEachDay(t *testing.T) { + inNewYork(t) + hotel := []Recording{{ID: 4823, Title: "Conference hotel stay", StartsAt: at("2026-09-16T18:00:00Z"), EndsAt: at("2026-09-18T15:00:00Z"), Type: "Calendar::Event"}} + const colWidth = 4 + cases := []struct { + day int + fromHour int + untilHour int + }{ + {16, 14, 24}, + {17, 0, 24}, + {18, 0, 11}, + } + for _, tc := range cases { + anchor := time.Date(2026, 9, tc.day, 12, 0, 0, 0, time.Local) + lines := strings.Split(stripANSI(renderDayView(hotel, nil, nil, anchor, calendarFixtureNow(), "", 100, 14, selection{}, nil, true)), "\n") + row := lines[rowContaining(t, lines, "f")] + column := len([]rune(row[:strings.Index(row, "f")])) + if column < tc.fromHour*colWidth || column >= tc.untilHour*colWidth { + t.Errorf("2026-09-%d: title at column %d, want inside hours %d to %d", tc.day, column, tc.fromHour, tc.untilHour) + } + } +} 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..1faf22378 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 } @@ -1264,6 +1260,21 @@ func buildWeekDayColumn(d weekDayInfo, width int, muted lipgloss.Style, sel sele return lines } +func dayColumns(starts, ends, anchor time.Time, daySpan int) (int, int) { + day := anchor.Local() + dayStart := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, time.Local) + dayEnd := time.Date(day.Year(), day.Month(), day.Day()+1, 0, 0, 0, 0, time.Local) + + 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 +} + // eventTimeSpan is when an event runs, on the clock the reader keeps — not the one HEY answers // in, which is what made a 14:00Z event read as 14:00 wherever they were. // 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) } } From 887fc1a452ad87efe353ef04b7a23693f5423d09 Mon Sep 17 00:00:00 2001 From: Armaan Singh <10168472+armada877@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:20:07 -0400 Subject: [PATCH 2/6] Bound series rows and the default list to the days asked for - hey event list keeps a series row only if the series starts before the end of the last day. The list reads a day either side, and a series that starts in that extra day is not in the window. - The default event list window is today and the 29 days after it. With --ends-on included, that is 30 days, as the help says. - The local-day test server records requests under a mutex, because the two neighbour reads run at the same time. - The docs and help no longer say that hey event edit uses the account's zone. It looks from the day before the date to the day after it. - docs/tui.md says that a timed event moves to its local day and an all-day event stays on its dates. - The day-before test case reads its event from the day before only. Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 19 +++++++------ docs/tui.md | 5 ++-- internal/calendarday/calendarday.go | 5 +++- internal/calendarday/calendarday_test.go | 6 ++-- internal/cmd/account_zone_test.go | 2 +- internal/cmd/calendar_commands_test.go | 4 +-- internal/cmd/events.go | 8 +++--- internal/cmd/events_local_day_test.go | 36 ++++++++++++++++++++---- internal/cmd/recording_filter.go | 2 +- 9 files changed, 61 insertions(+), 26 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index c0090c1d0..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,13 +460,16 @@ 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`, `hey event week` and `hey event edit` 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. +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 diff --git a/docs/tui.md b/docs/tui.md index daf2cfc0b..650985365 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -214,8 +214,9 @@ 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. An event belongs to the -local day it overlaps, and the app reads the day either side of the span to find it. +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 diff --git a/internal/calendarday/calendarday.go b/internal/calendarday/calendarday.go index 4e32da3f9..c5c752e06 100644 --- a/internal/calendarday/calendarday.go +++ b/internal/calendarday/calendarday.go @@ -90,9 +90,12 @@ func WithLocalEvents(main *generated.CalendarPeriod, neighbours []*generated.Cal } func InDays(r generated.Recording, first, last time.Time, loc *time.Location) bool { - if r.Recurring || r.StartsAt.IsZero() { + if r.StartsAt.IsZero() { return true } + if r.Recurring { + return r.StartsAt.Before(civilMidnight(last.AddDate(0, 0, 1), loc)) + } if r.AllDay { startsOn := utcDate(r.StartsAt) endsOn := startsOn diff --git a/internal/calendarday/calendarday_test.go b/internal/calendarday/calendarday_test.go index cc4b785b9..1d1f6f62a 100644 --- a/internal/calendarday/calendarday_test.go +++ b/internal/calendarday/calendarday_test.go @@ -146,7 +146,7 @@ func TestLocalEventsByZone(t *testing.T) { {name: "dinner is on the 21st in Tokyo", zone: "Asia/Tokyo", date: "2026-09-21", main: []generated.Recording{dinner}, want: []string{"Late dinner with Priya Patel"}}, {name: "dinner is not on the 20th in Tokyo", zone: "Asia/Tokyo", date: "2026-09-20", after: []generated.Recording{dinner}, wantNo: []string{"Late dinner with Priya Patel"}}, {name: "dinner is on the 21st in UTC", zone: "UTC", date: "2026-09-21", main: []generated.Recording{dinner}, want: []string{"Late dinner with Priya Patel"}}, - {name: "concert from before-day is on the 19th in UTC", zone: "UTC", date: "2026-09-19", main: []generated.Recording{concert}, want: []string{"Evening concert"}}, + {name: "concert from before-day is on the 19th in UTC", zone: "UTC", date: "2026-09-19", before: []generated.Recording{concert}, want: []string{"Evening concert"}}, } for _, tc := range cases { @@ -233,7 +233,9 @@ func TestInDays(t *testing.T) { {"evening dinner is not on the next day", generated.Recording{StartsAt: instant(t, "2026-09-21T00:50:00Z"), EndsAt: instant(t, "2026-09-21T03:00:00Z")}, "2026-09-21", "2026-09-21", false}, {"concert ended before the day", generated.Recording{StartsAt: instant(t, "2026-09-18T22:00:00Z"), EndsAt: instant(t, "2026-09-19T01:30:00Z")}, "2026-09-19", "2026-09-19", false}, {"concert is on the 18th", generated.Recording{StartsAt: instant(t, "2026-09-18T22:00:00Z"), EndsAt: instant(t, "2026-09-19T01:30:00Z")}, "2026-09-18", "2026-09-18", true}, - {"series row is always kept", generated.Recording{Recurring: true, StartsAt: instant(t, "2026-01-05T14:00:00Z")}, "2026-09-20", "2026-09-20", true}, + {"series that started a year before is listed", generated.Recording{Recurring: true, StartsAt: instant(t, "2025-09-15T14:00:00Z")}, "2026-09-20", "2026-09-20", true}, + {"series that starts the day after is not listed", generated.Recording{Recurring: true, StartsAt: instant(t, "2026-09-21T14:00:00Z")}, "2026-09-20", "2026-09-20", false}, + {"series that starts in the evening of the last day is listed", generated.Recording{Recurring: true, StartsAt: instant(t, "2026-09-21T02:00:00Z")}, "2026-09-20", "2026-09-20", true}, {"event without a start is kept", generated.Recording{}, "2026-09-20", "2026-09-20", true}, {"all-day event 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 event outside", generated.Recording{AllDay: true, StartsAt: day("2026-09-20"), EndsAt: day("2026-09-20")}, "2026-09-21", "2026-09-22", false}, diff --git a/internal/cmd/account_zone_test.go b/internal/cmd/account_zone_test.go index 908d035a4..1688bdd23 100644 --- a/internal/cmd/account_zone_test.go +++ b/internal/cmd/account_zone_test.go @@ -130,7 +130,7 @@ 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=" + start.AddDate(0, 0, -1).Format(dateLayout) + " ends_on=" + start.AddDate(0, 0, 32).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" }}, diff --git a/internal/cmd/calendar_commands_test.go b/internal/cmd/calendar_commands_test.go index c405510f0..a9791d6cb 100644 --- a/internal/cmd/calendar_commands_test.go +++ b/internal/cmd/calendar_commands_test.go @@ -269,8 +269,8 @@ func TestEventsListRefusesARepeatedRecordingsCursor(t *testing.T) { func TestEventsListDefaultsEndDateFromStart(t *testing.T) { 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-05" { - t.Errorf("ends_on = %q, want 30 days after 2026-02-01, and two days more for the zone", got) + 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, `{}`) diff --git a/internal/cmd/events.go b/internal/cmd/events.go index e0278bcd2..717cefe2a 100644 --- a/internal/cmd/events.go +++ b/internal/cmd/events.go @@ -75,7 +75,7 @@ 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.`, +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`, @@ -312,9 +312,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. -The read covers the day before and the day after it too, so an evening or an early morning -event is found by the date you see it on. +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 diff --git a/internal/cmd/events_local_day_test.go b/internal/cmd/events_local_day_test.go index 4b1fa9730..1cc342b62 100644 --- a/internal/cmd/events_local_day_test.go +++ b/internal/cmd/events_local_day_test.go @@ -4,6 +4,7 @@ import ( "io" "net/http" "strings" + "sync" "testing" "time" ) @@ -34,6 +35,7 @@ type localDayServer struct { zone string identityStatus int paths map[string]string + mu sync.Mutex identityReads int requestedPaths []string listedRecording string @@ -41,10 +43,10 @@ type localDayServer struct { func (s *localDayServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - s.requestedPaths = append(s.requestedPaths, r.URL.Path+"?"+r.URL.RawQuery) + s.record(r) switch r.URL.Path { case "/identity.json": - s.identityReads++ + s.countIdentityRead() if s.identityStatus != 0 { http.Error(w, `{"error":"identity unavailable"}`, s.identityStatus) return @@ -71,6 +73,30 @@ func (s *localDayServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +func (s *localDayServer) record(r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + s.requestedPaths = append(s.requestedPaths, r.URL.Path+"?"+r.URL.RawQuery) +} + +func (s *localDayServer) countIdentityRead() { + s.mu.Lock() + defer s.mu.Unlock() + s.identityReads++ +} + +func (s *localDayServer) requested() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.requestedPaths...) +} + +func (s *localDayServer) identityReadCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.identityReads +} + func eventIDsOf(t *testing.T, data any) []float64 { t.Helper() rows, ok := data.([]any) @@ -214,8 +240,8 @@ func TestEventsDayWithoutAnAccountZoneUsesTheMachinesDaysAndSaysSoOnce(t *testin if got := strings.Count(stderr, "Notice:"); got != 1 { t.Errorf("stderr has %d notices, want one: %q", got, stderr) } - if server.identityReads != 1 { - t.Errorf("identity read %d times, want once", server.identityReads) + if reads := server.identityReadCount(); reads != 1 { + t.Errorf("identity read %d times, want once", reads) } } @@ -329,7 +355,7 @@ func TestEventsListSummaryNamesTheDatesGiven(t *testing.T) { } wanted := "starts_on=2026-09-19&ends_on=2026-09-22" found := false - for _, path := range server.requestedPaths { + for _, path := range server.requested() { if strings.Contains(path, "/calendars/9102/recordings.json") && strings.Contains(path, "ends_on=2026-09-22") && strings.Contains(path, "starts_on=2026-09-19") { found = true } 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 From c37acbbac5820a4b58db2d431ec6938e5e08185c Mon Sep 17 00:00:00 2001 From: Armaan Singh <10168472+armada877@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:32:04 -0400 Subject: [PATCH 3/6] Test each local-day rule in one place The edge cases of the local-day rules are now table tests in internal/calendarday only. The CLI and TUI tests check only that each command and view uses those rules, with one test each. The three new test files go from 888 to 395 lines. The TUI takes the zone of its local days from the day on screen, which is time.Local in the app. The tests can then use a zone of their own without changing time.Local, which raced with other tests under -race. Co-Authored-By: Claude Opus 5.5 --- internal/calendarday/calendarday_test.go | 279 +++++---------- internal/cmd/events_local_day_test.go | 415 +++++------------------ internal/tui/calendar.go | 3 +- internal/tui/calendar_local_day_test.go | 247 +++----------- internal/tui/calendar_views.go | 6 +- 5 files changed, 229 insertions(+), 721 deletions(-) diff --git a/internal/calendarday/calendarday_test.go b/internal/calendarday/calendarday_test.go index 1d1f6f62a..3b8f69610 100644 --- a/internal/calendarday/calendarday_test.go +++ b/internal/calendarday/calendarday_test.go @@ -1,227 +1,112 @@ package calendarday import ( + "slices" "testing" "time" "github.com/basecamp/hey-sdk/go/pkg/generated" ) -func mustLocation(t *testing.T, name string) *time.Location { - t.Helper() - loc, err := time.LoadLocation(name) - if err != nil { - t.Fatalf("load %s: %v", name, err) - } - return loc -} - func instant(t *testing.T, value string) time.Time { t.Helper() at, err := time.Parse(time.RFC3339, value) if err != nil { - t.Fatalf("parse %s: %v", value, err) + t.Fatal(err) } return at } -func utcDay(t *testing.T, date string) *generated.CalendarPeriod { +func period(t *testing.T, first, last string, events ...generated.Recording) *generated.CalendarPeriod { t.Helper() - start := instant(t, date+"T00:00:00Z") return &generated.CalendarPeriod{ - Kind: "day", - StartsAt: start, - EndsAt: instant(t, date+"T23:59:59Z"), - Recordings: generated.CalendarRecordingsResponse{}, + StartsAt: instant(t, first+"T00:00:00Z"), + EndsAt: instant(t, last+"T23:59:59Z"), + Recordings: generated.CalendarRecordingsResponse{EventType: events}, } } -func withEvents(period *generated.CalendarPeriod, events ...generated.Recording) *generated.CalendarPeriod { - period.Recordings[EventType] = append(period.Recordings[EventType], events...) - return period -} - -func titles(events []generated.Recording) []string { - names := make([]string, 0, len(events)) - for _, event := range events { - names = append(names, event.Title) +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}, } - return names -} - -func hasTitle(events []generated.Recording, title string) bool { - for _, event := range events { - if event.Title == title { - return true + 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)) } } - return false -} - -func TestSpanOfDayEndingAtLastSecond(t *testing.T) { - span := SpanOf(utcDay(t, "2026-09-20")) - if got := span.First.Format(dateLayout); got != "2026-09-20" { - t.Errorf("First = %s", got) - } - if got := span.Last.Format(dateLayout); got != "2026-09-20" { - t.Errorf("Last = %s", got) - } -} - -func TestSpanOfWeek(t *testing.T) { - week := &generated.CalendarPeriod{ - Kind: "week", - StartsAt: instant(t, "2026-09-14T00:00:00Z"), - EndsAt: instant(t, "2026-09-20T23:59:59Z"), - } - span := SpanOf(week) - before, after := span.Neighbours() - if before != "2026-09-13" || after != "2026-09-21" { - t.Errorf("Neighbours = %s, %s", before, after) - } -} - -func TestSpanOfEndAtMidnightExcludesThatDay(t *testing.T) { - period := &generated.CalendarPeriod{ - StartsAt: instant(t, "2026-09-14T00:00:00Z"), - EndsAt: instant(t, "2026-09-21T00:00:00Z"), - } - if got := SpanOf(period).Last.Format(dateLayout); got != "2026-09-20" { - t.Errorf("Last = %s", got) - } -} - -func TestSpanLocalFollowsClockChange(t *testing.T) { - newYork := mustLocation(t, "America/New_York") - from, to := SpanOf(utcDay(t, "2026-11-01")).Local(newYork) - if got := to.Sub(from); got != 25*time.Hour { - t.Errorf("local day length = %s, want 25h", got) - } -} - -func TestOverlapsInstantEvent(t *testing.T) { - from := instant(t, "2026-09-20T04:00:00Z") - to := instant(t, "2026-09-21T04:00:00Z") - event := generated.Recording{StartsAt: to} - if Overlaps(event, from, to) { - t.Error("an instant at the end of the span must not overlap") - } - event.StartsAt = from - if !Overlaps(event, from, to) { - t.Error("an instant at the start of the span must overlap") - } -} - -func TestOverlapsEventEndingAtStartOfSpan(t *testing.T) { - from := instant(t, "2026-09-19T04:00:00Z") - to := instant(t, "2026-09-20T04:00:00Z") - event := generated.Recording{ - StartsAt: instant(t, "2026-09-18T22:00:00Z"), - EndsAt: from, - } - if Overlaps(event, from, to) { - t.Error("an event that ends when the span starts must not overlap") - } } -func TestLocalEventsByZone(t *testing.T) { - dinner := generated.Recording{Id: 4821, Title: "Late dinner with Priya Patel", StartsAt: instant(t, "2026-09-21T00:50:00Z"), EndsAt: instant(t, "2026-09-21T03:00:00Z")} - concert := generated.Recording{Id: 4822, Title: "Evening concert", StartsAt: instant(t, "2026-09-18T22:00:00Z"), EndsAt: instant(t, "2026-09-19T01:30:00Z")} - breakfast := generated.Recording{Id: 5, Title: "Breakfast", StartsAt: instant(t, "2026-09-20T13:00:00Z"), EndsAt: instant(t, "2026-09-20T14:00:00Z")} +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") + 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 string - zone string - date string - main []generated.Recording - before []generated.Recording - after []generated.Recording - want []string - wantNo []string + name, zone, date string + main, before, after []generated.Recording + want []string }{ - {name: "dinner is on the 20th in New York", zone: "America/New_York", date: "2026-09-20", main: []generated.Recording{breakfast}, after: []generated.Recording{dinner}, want: []string{"Breakfast", "Late dinner with Priya Patel"}}, - {name: "dinner is not on the 21st in New York", zone: "America/New_York", date: "2026-09-21", main: []generated.Recording{dinner}, wantNo: []string{"Late dinner with Priya Patel"}}, - {name: "concert is not on the 19th in New York", zone: "America/New_York", date: "2026-09-19", main: []generated.Recording{concert}, wantNo: []string{"Evening concert"}}, - {name: "concert is on the 18th in New York", zone: "America/New_York", date: "2026-09-18", main: []generated.Recording{concert}, want: []string{"Evening concert"}}, - {name: "dinner is on the 21st in Tokyo", zone: "Asia/Tokyo", date: "2026-09-21", main: []generated.Recording{dinner}, want: []string{"Late dinner with Priya Patel"}}, - {name: "dinner is not on the 20th in Tokyo", zone: "Asia/Tokyo", date: "2026-09-20", after: []generated.Recording{dinner}, wantNo: []string{"Late dinner with Priya Patel"}}, - {name: "dinner is on the 21st in UTC", zone: "UTC", date: "2026-09-21", main: []generated.Recording{dinner}, want: []string{"Late dinner with Priya Patel"}}, - {name: "concert from before-day is on the 19th in UTC", zone: "UTC", date: "2026-09-19", before: []generated.Recording{concert}, want: []string{"Evening concert"}}, + {"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}, + {"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) { - main := withEvents(utcDay(t, tc.date), tc.main...) - neighbours := []*generated.CalendarPeriod{ - withEvents(utcDay(t, "2026-09-01"), tc.before...), - withEvents(utcDay(t, "2026-09-01"), tc.after...), + 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) } - got := LocalEvents(main, neighbours, mustLocation(t, tc.zone)) - for _, title := range tc.want { - if !hasTitle(got, title) { - t.Errorf("missing %q in %v", title, titles(got)) - } - } - for _, title := range tc.wantNo { - if hasTitle(got, title) { - t.Errorf("unexpected %q in %v", title, titles(got)) - } + slices.Sort(got) + if !slices.Equal(got, tc.want) { + t.Errorf("got %v, want %v", got, tc.want) } }) } } -func TestLocalEventsDayOfClockChange(t *testing.T) { - newYork := mustLocation(t, "America/New_York") - lateNight := generated.Recording{Id: 1, Title: "Late show", StartsAt: instant(t, "2026-11-02T04:30:00Z"), EndsAt: instant(t, "2026-11-02T04:45:00Z")} - nextDay := generated.Recording{Id: 2, Title: "Early start", StartsAt: instant(t, "2026-11-02T05:00:00Z"), EndsAt: instant(t, "2026-11-02T06:00:00Z")} - main := utcDay(t, "2026-11-01") - after := withEvents(utcDay(t, "2026-11-02"), lateNight, nextDay) - - got := LocalEvents(main, []*generated.CalendarPeriod{after}, newYork) - if !hasTitle(got, "Late show") || hasTitle(got, "Early start") { - t.Errorf("got %v", titles(got)) - } -} - -func TestLocalEventsKeepAllDayOfMainOnly(t *testing.T) { - holiday := generated.Recording{Id: 9, Title: "Holiday", AllDay: true, StartsAt: instant(t, "2026-09-20T00:00:00Z"), EndsAt: instant(t, "2026-09-20T00:00:00Z")} - otherHoliday := generated.Recording{Id: 10, Title: "Other holiday", AllDay: true, StartsAt: instant(t, "2026-09-21T00:00:00Z"), EndsAt: instant(t, "2026-09-21T00:00:00Z")} - main := withEvents(utcDay(t, "2026-09-20"), holiday) - after := withEvents(utcDay(t, "2026-09-21"), otherHoliday) - - got := LocalEvents(main, []*generated.CalendarPeriod{after}, mustLocation(t, "America/New_York")) - if !hasTitle(got, "Holiday") || hasTitle(got, "Other holiday") { - t.Errorf("got %v", titles(got)) - } -} - -func TestLocalEventsDeduplicate(t *testing.T) { - overnight := generated.Recording{Id: 3, Title: "Night flight", StartsAt: instant(t, "2026-09-20T23:00:00Z"), EndsAt: instant(t, "2026-09-21T05:00:00Z")} - main := withEvents(utcDay(t, "2026-09-20"), overnight) - after := withEvents(utcDay(t, "2026-09-21"), overnight) - - got := LocalEvents(main, []*generated.CalendarPeriod{after}, mustLocation(t, "UTC")) - if len(got) != 1 { - t.Errorf("got %d events, want 1", len(got)) - } -} - -func TestLocalEventsDeduplicateOccurrencesWithoutId(t *testing.T) { - first := generated.Recording{OccurrenceId: "7_2026-09-20", Title: "Standup", StartsAt: instant(t, "2026-09-20T14:00:00Z")} - second := generated.Recording{OccurrenceId: "7_2026-09-21", Title: "Standup", StartsAt: instant(t, "2026-09-21T14:00:00Z")} - main := withEvents(utcDay(t, "2026-09-21"), second) - before := withEvents(utcDay(t, "2026-09-20"), first, second) - - got := LocalEvents(main, []*generated.CalendarPeriod{before}, mustLocation(t, "UTC")) - if len(got) != 1 { - t.Errorf("got %d events, want 1", len(got)) - } -} - func TestInDays(t *testing.T) { - newYork := mustLocation(t, "America/New_York") 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 @@ -229,23 +114,19 @@ func TestInDays(t *testing.T) { last string want bool }{ - {"evening dinner is on its local day", generated.Recording{StartsAt: instant(t, "2026-09-21T00:50:00Z"), EndsAt: instant(t, "2026-09-21T03:00:00Z")}, "2026-09-20", "2026-09-20", true}, - {"evening dinner is not on the next day", generated.Recording{StartsAt: instant(t, "2026-09-21T00:50:00Z"), EndsAt: instant(t, "2026-09-21T03:00:00Z")}, "2026-09-21", "2026-09-21", false}, - {"concert ended before the day", generated.Recording{StartsAt: instant(t, "2026-09-18T22:00:00Z"), EndsAt: instant(t, "2026-09-19T01:30:00Z")}, "2026-09-19", "2026-09-19", false}, - {"concert is on the 18th", generated.Recording{StartsAt: instant(t, "2026-09-18T22:00:00Z"), EndsAt: instant(t, "2026-09-19T01:30:00Z")}, "2026-09-18", "2026-09-18", true}, - {"series that started a year before is listed", generated.Recording{Recurring: true, StartsAt: instant(t, "2025-09-15T14:00:00Z")}, "2026-09-20", "2026-09-20", true}, - {"series that starts the day after is not listed", generated.Recording{Recurring: true, StartsAt: instant(t, "2026-09-21T14:00:00Z")}, "2026-09-20", "2026-09-20", false}, - {"series that starts in the evening of the last day is listed", generated.Recording{Recurring: true, StartsAt: instant(t, "2026-09-21T02:00:00Z")}, "2026-09-20", "2026-09-20", true}, - {"event without a start is kept", generated.Recording{}, "2026-09-20", "2026-09-20", true}, - {"all-day event 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 event outside", generated.Recording{AllDay: true, StartsAt: day("2026-09-20"), EndsAt: day("2026-09-20")}, "2026-09-21", "2026-09-22", false}, - {"multi-day all-day event overlaps", generated.Recording{AllDay: true, StartsAt: day("2026-09-18"), EndsAt: day("2026-09-22")}, "2026-09-20", "2026-09-20", true}, + {"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 { - t.Run(tc.name, func(t *testing.T) { - if got := InDays(tc.event, day(tc.first), day(tc.last), newYork); got != tc.want { - t.Errorf("InDays = %v, want %v", got, tc.want) - } - }) + 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) + } } } diff --git a/internal/cmd/events_local_day_test.go b/internal/cmd/events_local_day_test.go index 1cc342b62..1e65b7f80 100644 --- a/internal/cmd/events_local_day_test.go +++ b/internal/cmd/events_local_day_test.go @@ -10,388 +10,159 @@ import ( ) const ( - localDayDinner = `{"id":4821,"title":"Late dinner with Priya Patel","starts_at":"2026-09-21T00:50:00Z","ends_at":"2026-09-21T03:00:00Z","type":"Calendar::Event","calendar":{"id":9102,"name":"Personal"}}` - localDayConcert = `{"id":4822,"title":"Evening concert","starts_at":"2026-09-18T22:00:00Z","ends_at":"2026-09-19T01:30:00Z","type":"Calendar::Event","calendar":{"id":9102,"name":"Personal"}}` - 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","calendar":{"id":9102,"name":"Personal"}}` + 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(date string, events ...string) string { - return localDayJSONWith(date, strings.Join(events, ","), "") -} - -func localDayJSONWith(date, events, countdowns string) string { - body := `{"kind":"day","starts_at":"` + date + `T00:00:00Z","ends_at":"` + date + `T23:59:59Z","recordings":{"Calendar::Event":[` + events + `]` - if countdowns != "" { - body += `,"Calendar::Countdown":[` + countdowns + `]` - } - return body + `}}` -} - -func localWeekJSON(first, last string, events ...string) string { - return `{"kind":"week","starts_at":"` + first + `T00:00:00Z","ends_at":"` + last + `T23:59:59Z","recordings":{"Calendar::Event":[` + strings.Join(events, ",") + `]}}` +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 - identityStatus int - paths map[string]string - mu sync.Mutex - identityReads int - requestedPaths []string - listedRecording string + 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") - s.record(r) - switch r.URL.Path { - case "/identity.json": - s.countIdentityRead() - if s.identityStatus != 0 { - http.Error(w, `{"error":"identity unavailable"}`, s.identityStatus) - return - } + 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 "/calendars.json": + case r.URL.Path == "/calendars.json": _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9102,"name":"Personal","owned":true,"personal":true}}]}`) - case "/calendars/9102/recordings.json": - _, _ = io.WriteString(w, s.listedRecording) + 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: - if body, ok := s.paths[r.URL.Path]; ok { - _, _ = io.WriteString(w, body) - return - } - if date, ok := strings.CutPrefix(r.URL.Path, "/calendar/days/"); ok { - _, _ = io.WriteString(w, localDayJSON(strings.TrimSuffix(date, ".json"))) - return - } http.NotFound(w, r) } } -func (s *localDayServer) record(r *http.Request) { - s.mu.Lock() - defer s.mu.Unlock() - s.requestedPaths = append(s.requestedPaths, r.URL.Path+"?"+r.URL.RawQuery) -} - -func (s *localDayServer) countIdentityRead() { - s.mu.Lock() - defer s.mu.Unlock() - s.identityReads++ -} - -func (s *localDayServer) requested() []string { +func (s *localDayServer) readWidened() bool { s.mu.Lock() defer s.mu.Unlock() - return append([]string(nil), s.requestedPaths...) -} - -func (s *localDayServer) identityReadCount() int { - s.mu.Lock() - defer s.mu.Unlock() - return s.identityReads -} - -func eventIDsOf(t *testing.T, data any) []float64 { - t.Helper() - rows, ok := data.([]any) - if !ok { - t.Fatalf("data = %#v, want a list", data) - } - ids := make([]float64, 0, len(rows)) - for _, row := range rows { - ids = append(ids, row.(map[string]any)["id"].(float64)) - } - return ids -} - -func containsID(ids []float64, id float64) bool { - for _, candidate := range ids { - if candidate == id { + 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 TestEventsDayKeepsTheEventsOfTheAccountsLocalDay(t *testing.T) { - paths := map[string]string{ - "/calendar/days/2026-09-19.json": localDayJSON("2026-09-19", localDayConcert), - "/calendar/days/2026-09-20.json": localDayJSON("2026-09-20", localDayBreakfast), - "/calendar/days/2026-09-21.json": localDayJSON("2026-09-21", localDayDinner), - } - cases := []struct { - date string - want []float64 - wantNot []float64 - }{ - {date: "2026-09-20", want: []float64{4821, 4824}}, - {date: "2026-09-21", wantNot: []float64{4821}}, - {date: "2026-09-19", wantNot: []float64{4822}}, - {date: "2026-09-18", want: []float64{4822}}, - } - for _, tc := range cases { - t.Run(tc.date, func(t *testing.T) { - server := &localDayServer{zone: "America/New_York", paths: paths} - response, err := runJSONCommand(t, server, "event", "day", tc.date) - if err != nil { - t.Fatalf("event day %s: %v", tc.date, err) - } - ids := eventIDsOf(t, response.Data) - for _, id := range tc.want { - if !containsID(ids, id) { - t.Errorf("event %.0f missing from %v", id, ids) - } - } - for _, id := range tc.wantNot { - if containsID(ids, id) { - t.Errorf("event %.0f must not be on %s: %v", id, tc.date, ids) - } - } - }) +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 TestEventsDayCutsDaysInTheAccountZoneNotTheMachines(t *testing.T) { - inLocalZone(t, 9) +func TestEventsDayKeepsTheEveningEventWithItsCountdown(t *testing.T) { server := &localDayServer{zone: "America/New_York", paths: map[string]string{ - "/calendar/days/2026-09-21.json": localDayJSON("2026-09-21", localDayDinner), + "/calendar/days/2026-09-21.json": localDayJSON("day", "2026-09-21", "2026-09-21", localDayDinner, localDayCountdown), }} - response, err := runJSONCommand(t, server, "event", "day", "2026-09-20") - if err != nil { - t.Fatalf("event day: %v", err) + 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 !containsID(eventIDsOf(t, response.Data), 4821) { - t.Errorf("the dinner is on the account's 2026-09-20: %#v", response.Data) + 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 TestEventsWeekPlacesTheEveningDinnerInTheLocalWeek(t *testing.T) { +func TestEventsWeekKeepsAnEventFromTheNeighbourDay(t *testing.T) { server := &localDayServer{zone: "America/New_York", paths: map[string]string{ - "/calendar/weeks/2026-09-14.json": localWeekJSON("2026-09-14", "2026-09-20", localDayBreakfast), - "/calendar/days/2026-09-21.json": localDayJSON("2026-09-21", localDayDinner), + "/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, ""), }} - response, err := runJSONCommand(t, server, "event", "week", "2026-09-14") - if err != nil { - t.Fatalf("event week: %v", err) - } - if ids := eventIDsOf(t, response.Data); !containsID(ids, 4821) || !containsID(ids, 4824) { - t.Errorf("week ids = %v, want the dinner and breakfast", ids) + 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 TestEventsWeekDropsAnEventThatFallsInTheNextLocalWeek(t *testing.T) { - lateSunday := `{"id":4830,"title":"Sunday night film","starts_at":"2026-09-21T03:30:00Z","ends_at":"2026-09-21T05:30:00Z","type":"Calendar::Event"}` - nextMonday := `{"id":4831,"title":"Monday standup","starts_at":"2026-09-21T14:00:00Z","ends_at":"2026-09-21T14:30:00Z","type":"Calendar::Event"}` - server := &localDayServer{zone: "America/New_York", paths: map[string]string{ - "/calendar/weeks/2026-09-14.json": localWeekJSON("2026-09-14", "2026-09-20"), - "/calendar/days/2026-09-21.json": localDayJSON("2026-09-21", lateSunday, nextMonday), - }} - response, err := runJSONCommand(t, server, "event", "week", "2026-09-14") - if err != nil { - t.Fatalf("event week: %v", err) +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") } - ids := eventIDsOf(t, response.Data) - if !containsID(ids, 4830) || containsID(ids, 4831) { - t.Errorf("week ids = %v, want the Sunday film and not Monday's standup", ids) + if !strings.Contains(got, "Late dinner") || strings.Contains(got, "Evening concert") { + t.Errorf("list = %s, want the dinner and not the concert", got) } } -func TestEventsDayKeepsTheCountdownOfAnEventFromTheNeighbouringDay(t *testing.T) { - countdown := `{"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"}` - server := &localDayServer{zone: "America/New_York", paths: map[string]string{ - "/calendar/days/2026-09-21.json": localDayJSONWith("2026-09-21", localDayDinner, countdown), - }} - response, err := runJSONCommand(t, server, "event", "day", "2026-09-20") - if err != nil { - t.Fatalf("event day: %v", err) - } - rows := response.Data.([]any) - if len(rows) != 1 { - t.Fatalf("rows = %#v, want the dinner", rows) +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) } - labelled, ok := rows[0].(map[string]any)["countdown"].(map[string]any) - if !ok || labelled["label"] != "1 day before" { - t.Errorf("countdown = %#v, want the neighbour's countdown", rows[0]) + if !server.readWidened() { + t.Error("want the widened window") } } -func TestEventsDayWithoutAnAccountZoneUsesTheMachinesDaysAndSaysSoOnce(t *testing.T) { +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 }) - server := &localDayServer{paths: map[string]string{ - "/calendar/days/2026-09-21.json": localDayJSON("2026-09-21", localDayDinner), - }} stdout, stderr, err := runFormattedCommandWithStderr(t, server, []string{"--json"}, "event", "day") - if err != nil { - t.Fatalf("event day: %v", err) - } - if !strings.Contains(stdout, "4821") { - t.Errorf("stdout = %s, want the dinner", stdout) - } - if got := strings.Count(stderr, "Notice:"); got != 1 { - t.Errorf("stderr has %d notices, want one: %q", got, stderr) - } - if reads := server.identityReadCount(); reads != 1 { - t.Errorf("identity read %d times, want once", reads) - } -} - -func TestEventsDayWithADateAndNoAccountZoneSaysSo(t *testing.T) { - inLocalZone(t, -4) - server := &localDayServer{paths: map[string]string{ - "/calendar/days/2026-09-21.json": localDayJSON("2026-09-21", localDayDinner), - }} - stdout, stderr, err := runFormattedCommandWithStderr(t, server, []string{"--json"}, "event", "day", "2026-09-20") - if err != nil { - t.Fatalf("event day: %v", err) - } - if !strings.Contains(stdout, "4821") { - t.Errorf("stdout = %s, want the dinner", stdout) - } - if !strings.Contains(stderr, "so days are this machine's days") || strings.Count(stderr, "Notice:") != 1 { - t.Errorf("stderr = %q, want one notice", stderr) - } -} - -func TestEventsDayRefusesWhenTheIdentityCannotBeRead(t *testing.T) { - server := &localDayServer{identityStatus: http.StatusInternalServerError} - if _, err := runJSONCommand(t, server, "event", "day", "2026-09-20"); err == nil { - t.Error("an unreadable identity must fail the read") - } -} - -func TestEventsDayFailsWhenANeighbouringDayCannotBeRead(t *testing.T) { - server := &localDayServer{zone: "UTC"} - failing := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/calendar/days/2026-09-21.json" { - http.Error(w, `{"error":"unavailable"}`, http.StatusInternalServerError) - return - } - server.ServeHTTP(w, r) - }) - if _, err := runJSONCommand(t, failing, "event", "day", "2026-09-20"); err == nil { - t.Error("a failed neighbouring read must fail the day") - } -} - -const ( - localDaySeries = `{"id":204,"title":"Weekly planning","recurring":true,"starts_at":"2026-01-05T14:00:00Z","ends_at":"2026-01-05T15:00:00Z","type":"Calendar::Event"}` - localDayAllDay = `{"id":4825,"title":"Maria Lopez's birthday","all_day":true,"starts_at":"2026-09-20T00:00:00Z","ends_at":"2026-09-20T00:00:00Z","type":"Calendar::Event"}` - localDayNextAllDay = `{"id":4826,"title":"Daylight saving ends","all_day":true,"starts_at":"2026-11-01T00:00:00Z","ends_at":"2026-11-01T00:00:00Z","type":"Calendar::Event"}` - localDayRecordings = `{"Calendar::Event":[` + localDayDinner + `,` + localDayConcert + `,` + localDayBreakfast + `,` + localDaySeries + `,` + localDayAllDay + `,` + localDayNextAllDay + `]}` -) - -func TestEventsListKeepsTheEventsOfTheLocalDays(t *testing.T) { - cases := []struct { - name string - args []string - want []float64 - wantNot []float64 - }{ - { - name: "one day", - args: []string{"--starts-on", "2026-09-20", "--ends-on", "2026-09-20"}, - want: []float64{4821, 4824, 204, 4825}, - wantNot: []float64{4822, 4826}, - }, - { - name: "the day after leaves the evening dinner behind", - args: []string{"--starts-on", "2026-09-21", "--ends-on", "2026-09-21"}, - want: []float64{204}, - wantNot: []float64{4821, 4822, 4825}, - }, - { - name: "ends-on is inclusive", - args: []string{"--starts-on", "2026-09-19", "--ends-on", "2026-09-20"}, - want: []float64{4821, 4824, 4825}, - wantNot: []float64{4822, 4826}, - }, - { - name: "the concert is on the day it started", - args: []string{"--starts-on", "2026-09-18", "--ends-on", "2026-09-18"}, - want: []float64{4822}, - }, + if err != nil || !strings.Contains(stdout, "Late dinner") { + t.Fatalf("event day = %s, %v", stdout, err) } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - server := &localDayServer{zone: "America/New_York", listedRecording: localDayRecordings} - args := append([]string{"event", "list", "--calendar", "9102"}, tc.args...) - response, err := runJSONCommand(t, server, args...) - if err != nil { - t.Fatalf("event list: %v", err) - } - ids := eventIDsOf(t, response.Data) - for _, id := range tc.want { - if !containsID(ids, id) { - t.Errorf("event %.0f missing from %v", id, ids) - } - } - for _, id := range tc.wantNot { - if containsID(ids, id) { - t.Errorf("event %.0f must not be listed: %v", id, ids) - } - } - }) + 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 TestEventsListSummaryNamesTheDatesGiven(t *testing.T) { - server := &localDayServer{zone: "America/New_York", listedRecording: localDayRecordings} - response, err := runJSONCommand(t, server, "event", "list", "--calendar", "9102", "--starts-on", "2026-09-20", "--ends-on", "2026-09-20") - if err != nil { - t.Fatalf("event list: %v", err) - } - if !strings.Contains(response.Summary, "(2026-09-20 to 2026-09-20)") { - t.Errorf("summary = %q, want the dates the reader gave", response.Summary) - } - wanted := "starts_on=2026-09-19&ends_on=2026-09-22" - found := false - for _, path := range server.requested() { - if strings.Contains(path, "/calendars/9102/recordings.json") && strings.Contains(path, "ends_on=2026-09-22") && strings.Contains(path, "starts_on=2026-09-19") { - found = true +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) } } - if !found { - t.Errorf("requests = %v, want a read with %s", server.requestedPaths, wanted) - } -} - -func TestEventsEditFindsAnEveningEventByItsLocalDate(t *testing.T) { - server := &localDayServer{zone: "America/New_York"} - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/calendars/9102/recordings.json" { - endsOn, err := time.Parse(dateLayout, r.URL.Query().Get("ends_on")) - if err != nil { - t.Errorf("ends_on = %q", r.URL.Query().Get("ends_on")) - } - w.Header().Set("Content-Type", "application/json") - dinnerStart := time.Date(2026, 9, 21, 0, 50, 0, 0, time.UTC) - if dinnerStart.After(endsOn) { - _, _ = io.WriteString(w, `{"Calendar::Event":[]}`) - return - } - _, _ = io.WriteString(w, `{"Calendar::Event":[`+localDayDinner+`]}`) - return - } - if r.Method == http.MethodPatch { - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"id":4821,"title":"Late dinner with Priya Patel and Tom"}`) - return - } - server.ServeHTTP(w, r) - }) - - _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-09-20", "--title", "Late dinner with Priya Patel and Tom") - if err != nil { - t.Fatalf("event edit by the local date: %v", err) - } } diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index a8bde1cbb..bf3dfaac0 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -2536,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 { @@ -2565,7 +2566,7 @@ func (v *calendarView) requestRecordings() tea.Cmd { if err != nil { return recordingsLoadedMsg{requestResult: newRequestResult(requestID, err)} } - local := calendarday.WithLocalEvents(period, neighbours, time.Local) + 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 index 0b94339eb..bee9e670d 100644 --- a/internal/tui/calendar_local_day_test.go +++ b/internal/tui/calendar_local_day_test.go @@ -1,7 +1,6 @@ package tui import ( - "fmt" "net/http" "net/http/httptest" "strings" @@ -11,230 +10,86 @@ import ( hey "github.com/basecamp/hey-sdk/go/pkg/hey" ) -const ( - dinnerJSON = `{"id":4821,"title":"Late dinner with Priya Patel","type":"CalendarEvent","starts_at":"2026-09-21T00:50:00Z","ends_at":"2026-09-21T03:00:00Z"}` - concertJSON = `{"id":4822,"title":"Evening concert","type":"CalendarEvent","starts_at":"2026-09-18T22:00:00Z","ends_at":"2026-09-19T01:30:00Z"}` - breakfastJSON = `{"id":4824,"title":"Breakfast with Maria Lopez","type":"CalendarEvent","starts_at":"2026-09-20T13:00:00Z","ends_at":"2026-09-20T14:00:00Z"}` -) - -func inNewYork(t *testing.T) { +func newYork(t *testing.T) *time.Location { t.Helper() - newYork, err := time.LoadLocation("America/New_York") + loc, err := time.LoadLocation("America/New_York") if err != nil { t.Skipf("time zone database unavailable: %v", err) } - previous := time.Local - time.Local = newYork - t.Cleanup(func() { time.Local = previous }) + return loc } func dayJSON(date string, events ...string) string { - return fmt.Sprintf(`{"starts_at":"%sT00:00:00Z","ends_at":"%sT23:59:59Z","kind":"day","recordings":{"Calendar::Event":[%s]}}`, - date, date, strings.Join(events, ",")) -} - -func weekJSON(first, last string, events ...string) string { - return fmt.Sprintf(`{"starts_at":"%sT00:00:00Z","ends_at":"%sT23:59:59Z","kind":"week","recordings":{"Calendar::Event":[%s]}}`, - first, last, strings.Join(events, ",")) -} - -func localDayView(t *testing.T, mode calendarViewMode, day time.Time, answers map[string]string) *calendarView { - t.Helper() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", "application/json") - if body, ok := answers[req.URL.Path]; ok { - _, _ = w.Write([]byte(body)) - return - } - date := strings.TrimSuffix(strings.TrimPrefix(req.URL.Path, "/calendar/days/"), ".json") - _, _ = w.Write([]byte(dayJSON(date))) - })) - t.Cleanup(server.Close) - - vc := testVC() - 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 day } - v.viewMode = mode - return v -} - -func readTitles(t *testing.T, v *calendarView) []string { - t.Helper() - msg, ok := v.requestRecordings()().(recordingsLoadedMsg) - if !ok { - t.Fatal("read did not answer recordings") - } - if msg.err != nil { - t.Fatalf("read failed: %v", msg.err) - } - titles := make([]string, 0, len(msg.recordings)) - for _, recording := range msg.recordings { - titles = append(titles, recording.Title) - } - return titles + return `{"starts_at":"` + date + `T00:00:00Z","ends_at":"` + date + `T23:59:59Z","kind":"day","recordings":{"Calendar::Event":[` + strings.Join(events, ",") + `]}}` } -func hasTitle(titles []string, prefix string) bool { - for _, title := range titles { - if strings.HasPrefix(title, prefix) { - return true - } - } - return false -} - -func TestCalendarDayViewShowsTheEveningDinnerOnItsLocalDay(t *testing.T) { - inNewYork(t) +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-20.json": dayJSON("2026-09-20", breakfastJSON), - "/calendar/days/2026-09-21.json": dayJSON("2026-09-21", dinnerJSON), + "/calendar/days/2026-09-19.json": dayJSON("2026-09-19", concert), + "/calendar/days/2026-09-21.json": dayJSON("2026-09-21", dinner), } - day := time.Date(2026, 9, 20, 12, 0, 0, 0, time.Local) - titles := readTitles(t, localDayView(t, viewDay, day, answers)) - - if !hasTitle(titles, "Late dinner with Priya Patel") || !hasTitle(titles, "Breakfast") { - t.Errorf("titles of 2026-09-20 = %v, want the dinner and breakfast", titles) - } -} - -func TestCalendarDayViewDropsTheEveningDinnerFromTheNextLocalDay(t *testing.T) { - inNewYork(t) - answers := map[string]string{ - "/calendar/days/2026-09-21.json": dayJSON("2026-09-21", dinnerJSON), - } - day := time.Date(2026, 9, 21, 12, 0, 0, 0, time.Local) - titles := readTitles(t, localDayView(t, viewDay, day, answers)) - - if hasTitle(titles, "Late dinner with Priya Patel") { - t.Errorf("titles of 2026-09-21 = %v, want no dinner", titles) - } -} - -func TestCalendarDayViewDropsTheBusThatEndedTheDayBefore(t *testing.T) { - inNewYork(t) - answers := map[string]string{ - "/calendar/days/2026-09-19.json": dayJSON("2026-09-19", concertJSON), - "/calendar/days/2026-09-18.json": dayJSON("2026-09-18", concertJSON), - } - day := time.Date(2026, 9, 19, 12, 0, 0, 0, time.Local) - titles := readTitles(t, localDayView(t, viewDay, day, answers)) - - if hasTitle(titles, "Evening concert") { - t.Errorf("titles of 2026-09-19 = %v, want no concert", titles) - } -} - -func TestCalendarWeekViewPlacesTheEveningDinnerOnSunday(t *testing.T) { - inNewYork(t) - answers := map[string]string{ - "/calendar/weeks/2026-09-17.json": weekJSON("2026-09-14", "2026-09-20", breakfastJSON), - "/calendar/days/2026-09-21.json": dayJSON("2026-09-21", dinnerJSON), - } - day := time.Date(2026, 9, 17, 12, 0, 0, 0, time.Local) - v := localDayView(t, viewWeek, day, answers) - msg := v.requestRecordings()().(recordingsLoadedMsg) - if msg.err != nil { - t.Fatalf("read failed: %v", msg.err) - } - - byDate := eventsByDate(msg.recordings) - sunday := byDate["2026-09-20"] - foundDinner := false - for _, event := range sunday { - if strings.HasPrefix(event.Title, "Late dinner with Priya Patel") { - foundDinner = true - } - } - if !foundDinner { - t.Errorf("events on 2026-09-20 = %v, want the dinner", sunday) - } - if len(byDate["2026-09-21"]) != 0 { - t.Errorf("events on 2026-09-21 = %v, want none", byDate["2026-09-21"]) - } -} - -func TestCalendarReadFailsWhenANeighbourDayFails(t *testing.T) { - inNewYork(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", "application/json") - if req.URL.Path == "/calendar/days/2026-09-21.json" { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"error":"neighbour failed"}`)) - return + body, ok := answers[req.URL.Path] + if !ok { + body = dayJSON(strings.TrimSuffix(strings.TrimPrefix(req.URL.Path, "/calendar/days/"), ".json")) } - _, _ = w.Write([]byte(dayJSON("2026-09-20", breakfastJSON))) + 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)) - v := newCalendarView(vc) - v.Resize(80, 30) - v.now = func() time.Time { return time.Date(2026, 9, 20, 12, 0, 0, 0, time.Local) } - - msg := v.requestRecordings()().(recordingsLoadedMsg) - if msg.err == nil { - t.Error("a failed neighbour read must fail the whole read") - } -} - -func TestDayColumnsClipASpanningEventToTheDay(t *testing.T) { - inNewYork(t) - const daySpan = 96 - dinner := Recording{StartsAt: at("2026-09-21T00:50:00Z"), EndsAt: at("2026-09-21T04:20:00Z")} - hotel := Recording{StartsAt: at("2026-09-16T18:00:00Z"), EndsAt: at("2026-09-18T15:00:00Z")} - anchorOn := func(day int) time.Time { return time.Date(2026, 9, day, 12, 0, 0, 0, time.Local) } - cases := []struct { - name string - event Recording - day int - wantStartPos int - wantEndPos int + day int + title string + wantShown bool }{ - {"dinner on its evening", dinner, 20, 20*60 + 50, daySpan}, - {"dinner after midnight", dinner, 21, 0, 20}, - {"hotel fills a middle day", hotel, 17, 0, daySpan}, - {"hotel on its last day", hotel, 18, 0, 11 * 60}, - {"hotel on its first day", hotel, 16, 14 * 60, daySpan}, + {20, "Late dinner", true}, + {21, "Late dinner", false}, + {19, "Evening concert", false}, } for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - start, end := dayColumns(tc.event.Starts(), tc.event.Ends(), anchorOn(tc.day), daySpan) - wantStart := tc.wantStartPos * daySpan / (24 * 60) - wantEnd := tc.wantEndPos * daySpan / (24 * 60) - if tc.wantEndPos == daySpan { - wantEnd = daySpan - } - if start != wantStart || end != wantEnd { - t.Errorf("columns = %d..%d, want %d..%d", start, end, wantStart, wantEnd) - } - }) + 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 TestDayViewDrawsASpanningEventAcrossTheHoursOfEachDay(t *testing.T) { - inNewYork(t) - hotel := []Recording{{ID: 4823, Title: "Conference hotel stay", StartsAt: at("2026-09-16T18:00:00Z"), EndsAt: at("2026-09-18T15:00:00Z"), Type: "Calendar::Event"}} - const colWidth = 4 +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 { - day int - fromHour int - untilHour int + name string + event Recording + day int + start, end int }{ - {16, 14, 24}, - {17, 0, 24}, - {18, 0, 11}, + {"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, time.Local) - lines := strings.Split(stripANSI(renderDayView(hotel, nil, nil, anchor, calendarFixtureNow(), "", 100, 14, selection{}, nil, true)), "\n") - row := lines[rowContaining(t, lines, "f")] - column := len([]rune(row[:strings.Index(row, "f")])) - if column < tc.fromHour*colWidth || column >= tc.untilHour*colWidth { - t.Errorf("2026-09-%d: title at column %d, want inside hours %d to %d", tc.day, column, tc.fromHour, tc.untilHour) + 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) } } } diff --git a/internal/tui/calendar_views.go b/internal/tui/calendar_views.go index 1faf22378..9e5d14409 100644 --- a/internal/tui/calendar_views.go +++ b/internal/tui/calendar_views.go @@ -1261,9 +1261,9 @@ func buildWeekDayColumn(d weekDayInfo, width int, muted lipgloss.Style, sel sele } func dayColumns(starts, ends, anchor time.Time, daySpan int) (int, int) { - day := anchor.Local() - dayStart := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, time.Local) - dayEnd := time.Date(day.Year(), day.Month(), day.Day()+1, 0, 0, 0, 0, time.Local) + 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) { From 2dac491c0bd9967d637f35f5bdf1db09e9c15720 Mon Sep 17 00:00:00 2001 From: Armaan Singh <10168472+armada877@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:40:16 -0400 Subject: [PATCH 4/6] Test the day's start boundary and the rendered day view - calendarday: an event that ends exactly at local midnight of the day is not on that day. - tui: the rendered day view starts a multi-day stay at the first hour of its middle day, so the view uses the clipped columns. Co-Authored-By: Claude Opus 5.5 --- internal/calendarday/calendarday_test.go | 2 ++ internal/tui/calendar_local_day_test.go | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/internal/calendarday/calendarday_test.go b/internal/calendarday/calendarday_test.go index 3b8f69610..b692357d7 100644 --- a/internal/calendarday/calendarday_test.go +++ b/internal/calendarday/calendarday_test.go @@ -60,6 +60,7 @@ func TestLocalEvents(t *testing.T) { 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 @@ -75,6 +76,7 @@ func TestLocalEvents(t *testing.T) { {"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}, diff --git a/internal/tui/calendar_local_day_test.go b/internal/tui/calendar_local_day_test.go index bee9e670d..486b6c495 100644 --- a/internal/tui/calendar_local_day_test.go +++ b/internal/tui/calendar_local_day_test.go @@ -93,3 +93,17 @@ func TestDayColumnsClipASpanningEventToTheDay(t *testing.T) { } } } + +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) + } + } +} From c5b3feece7ced2b897682bbae989f496ac7c7d49 Mon Sep 17 00:00:00 2001 From: Armaan Singh <10168472+armada877@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:56:46 -0400 Subject: [PATCH 5/6] Document the local-day code and order it as STYLE.md asks Give calendarday a package comment, and give each new function and type a doc comment in the voice of the code around it. Put the public functions before the private ones and the methods last, each below its callers. Move dayColumns below renderDayView, its only caller. No behaviour changes. Co-Authored-By: Claude Opus 5.5 --- internal/calendarday/calendarday.go | 129 ++++++++++++++++++---------- internal/cmd/account_zone.go | 3 + internal/cmd/events.go | 3 + internal/cmd/events_period.go | 5 ++ internal/tui/calendar_views.go | 33 +++---- 5 files changed, 111 insertions(+), 62 deletions(-) diff --git a/internal/calendarday/calendarday.go b/internal/calendarday/calendarday.go index c5c752e06..812f7f215 100644 --- a/internal/calendarday/calendarday.go +++ b/internal/calendarday/calendarday.go @@ -1,3 +1,6 @@ +// 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 ( @@ -10,45 +13,26 @@ import ( ) const ( - EventType = "Calendar::Event" + // 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 } -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} -} - -func (s Span) Neighbours() (before, after string) { - return s.First.AddDate(0, 0, -1).Format(dateLayout), s.Last.AddDate(0, 0, 1).Format(dateLayout) -} - -func (s Span) Local(loc *time.Location) (from, to time.Time) { - return civilMidnight(s.First, loc), civilMidnight(s.Last.AddDate(0, 0, 1), loc) -} - -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) -} - +// 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 @@ -76,6 +60,8 @@ func ReadNeighbours(ctx context.Context, main *generated.CalendarPeriod, readDay 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 @@ -89,24 +75,9 @@ func WithLocalEvents(main *generated.CalendarPeriod, neighbours []*generated.Cal return &local } -func InDays(r generated.Recording, first, last time.Time, loc *time.Location) bool { - if r.StartsAt.IsZero() { - return true - } - if r.Recurring { - 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)) -} - +// 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 @@ -141,18 +112,82 @@ func LocalEvents(main *generated.CalendarPeriod, neighbours []*generated.Calenda 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. +func InDays(r generated.Recording, first, last time.Time, loc *time.Location) bool { + if r.StartsAt.IsZero() { + return true + } + if r.Recurring { + 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/cmd/account_zone.go b/internal/cmd/account_zone.go index 76f8b8c51..6e3cec5fa 100644 --- a/internal/cmd/account_zone.go +++ b/internal/cmd/account_zone.go @@ -96,6 +96,9 @@ 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 { diff --git a/internal/cmd/events.go b/internal/cmd/events.go index 717cefe2a..790107f20 100644 --- a/internal/cmd/events.go +++ b/internal/cmd/events.go @@ -120,6 +120,9 @@ 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 { diff --git a/internal/cmd/events_period.go b/internal/cmd/events_period.go index 1a5cdb7c1..fd97772a3 100644 --- a/internal/cmd/events_period.go +++ b/internal/cmd/events_period.go @@ -195,6 +195,9 @@ func (c *eventsPeriodCommand) date(ctx context.Context, cmd *cobra.Command, acco 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 @@ -229,6 +232,8 @@ func localPeriodRecordings(ctx context.Context, cmd *cobra.Command, account *acc 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)) diff --git a/internal/tui/calendar_views.go b/internal/tui/calendar_views.go index 9e5d14409..04d7351a1 100644 --- a/internal/tui/calendar_views.go +++ b/internal/tui/calendar_views.go @@ -769,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 @@ -1260,21 +1278,6 @@ func buildWeekDayColumn(d weekDayInfo, width int, muted lipgloss.Style, sel sele return lines } -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 -} - // eventTimeSpan is when an event runs, on the clock the reader keeps — not the one HEY answers // in, which is what made a 14:00Z event read as 14:00 wherever they were. // From 1858dae42827e13ea812b42e70450f4276f064af Mon Sep 17 00:00:00 2001 From: Armaan Singh <10168472+armada877@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:10:57 -0400 Subject: [PATCH 6/6] Compare an all-day series with the list's days by its date HEY stamps an all-day series at UTC midnight. InDays compared every series by its instant, so west of UTC a series that starts the day after --ends-on started before that day's local midnight and was listed. Compare an all-day series by its date, and keep the instant for a timed one. Co-Authored-By: Claude Opus 5.5 --- internal/calendarday/calendarday.go | 6 +++++- internal/calendarday/calendarday_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/internal/calendarday/calendarday.go b/internal/calendarday/calendarday.go index 812f7f215..19d06498a 100644 --- a/internal/calendarday/calendarday.go +++ b/internal/calendarday/calendarday.go @@ -114,12 +114,16 @@ func LocalEvents(main *generated.CalendarPeriod, neighbours []*generated.Calenda // 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. +// 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 { diff --git a/internal/calendarday/calendarday_test.go b/internal/calendarday/calendarday_test.go index b692357d7..9adb56280 100644 --- a/internal/calendarday/calendarday_test.go +++ b/internal/calendarday/calendarday_test.go @@ -132,3 +132,27 @@ func TestInDays(t *testing.T) { } } } + +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) + } + } +}