Repository navigation
Show a timed event on its local day, not its UTC day #563
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
armada877
wants to merge
6
commits into
basecamp:main
Choose a base branch
from
armada877:local-day-fix
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
91e7484
Show a timed event on its local day, not its UTC day
armada877 887fc1a
Bound series rows and the default list to the days asked for
armada877 c37acbb
Test each local-day rule in one place
armada877 2dac491
Test the day's start boundary and the rendered day view
armada877 c5b3fee
Document the local-day code and order it as STYLE.md asks
armada877 1858dae
Compare an all-day series with the list's days by its date
armada877 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,197 @@ | ||
| // Package calendarday finds the events of the reader's local days. HEY reads a date as a | ||
| // UTC day, so a timed event can sit on the day before or after the one the reader sees. | ||
| // The package finds those events from the read of the days and from its neighbour days. | ||
| package calendarday | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "sync" | ||
| "time" | ||
|
|
||
| "github.com/basecamp/hey-sdk/go/pkg/generated" | ||
| ) | ||
|
|
||
| const ( | ||
| // EventType is the key HEY files a calendar's events under in a period's recordings. | ||
| EventType = "Calendar::Event" | ||
|
|
||
| dateLayout = "2006-01-02" | ||
| ) | ||
|
|
||
| // Span is the run of UTC days a period covers, from First to Last inclusive. It is how a | ||
| // read of HEY is told apart from the local days the reader asked for. | ||
| type Span struct { | ||
| First time.Time | ||
| Last time.Time | ||
| } | ||
|
|
||
| // DayReader reads one UTC day by its date. The CLI and the TUI each hand the package their | ||
| // own, so it never sees the SDK client or how a calendar is chosen. | ||
| type DayReader func(ctx context.Context, date string) (*generated.CalendarPeriod, error) | ||
|
|
||
| // ReadNeighbours reads the day before and the day after a period, at the same time. An | ||
| // event of a local day can sit on either UTC day, and a failed read is an error because a | ||
| // day with a missing neighbour would silently lose events. | ||
| func ReadNeighbours(ctx context.Context, main *generated.CalendarPeriod, readDay DayReader) ([]*generated.CalendarPeriod, error) { | ||
| if main == nil || main.StartsAt.IsZero() { | ||
| return nil, nil | ||
| } | ||
| before, after := SpanOf(main).Neighbours() | ||
| dates := []string{before, after} | ||
| periods := make([]*generated.CalendarPeriod, len(dates)) | ||
| failures := make([]error, len(dates)) | ||
|
|
||
| var group sync.WaitGroup | ||
| for i, date := range dates { | ||
| group.Add(1) | ||
| go func() { | ||
| defer group.Done() | ||
| periods[i], failures[i] = readDay(ctx, date) | ||
| }() | ||
| } | ||
| group.Wait() | ||
|
|
||
| for _, err := range failures { | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| } | ||
| return periods, nil | ||
| } | ||
|
|
||
| // WithLocalEvents is a copy of a period whose events are the ones on its local days. The | ||
| // period read from HEY stays as it was, because other readers share it. | ||
| func WithLocalEvents(main *generated.CalendarPeriod, neighbours []*generated.CalendarPeriod, loc *time.Location) *generated.CalendarPeriod { | ||
| if main == nil || main.StartsAt.IsZero() { | ||
| return main | ||
| } | ||
| local := *main | ||
| local.Recordings = make(generated.CalendarRecordingsResponse, len(main.Recordings)) | ||
| for kind, recordings := range main.Recordings { | ||
| local.Recordings[kind] = recordings | ||
| } | ||
| local.Recordings[EventType] = LocalEvents(main, neighbours, loc) | ||
| return &local | ||
| } | ||
|
|
||
| // LocalEvents are the events that overlap the local days of a period, once each. An | ||
| // all-day event is a date rather than an instant, so only the period's own read decides it. | ||
| // A timed event that spans two local days is on both. | ||
| func LocalEvents(main *generated.CalendarPeriod, neighbours []*generated.CalendarPeriod, loc *time.Location) []generated.Recording { | ||
| if main == nil { | ||
| return nil | ||
| } | ||
| from, to := SpanOf(main).Local(loc) | ||
| events := []generated.Recording{} | ||
| seen := map[string]bool{} | ||
| keep := func(event generated.Recording) { | ||
| key := identity(event) | ||
| if seen[key] { | ||
| return | ||
| } | ||
| seen[key] = true | ||
| events = append(events, event) | ||
| } | ||
|
|
||
| for _, event := range main.Recordings[EventType] { | ||
| if event.AllDay || Overlaps(event, from, to) { | ||
| keep(event) | ||
| } | ||
| } | ||
| for _, neighbour := range neighbours { | ||
| if neighbour == nil { | ||
| continue | ||
| } | ||
| for _, event := range neighbour.Recordings[EventType] { | ||
| if !event.AllDay && Overlaps(event, from, to) { | ||
| keep(event) | ||
| } | ||
| } | ||
| } | ||
| return events | ||
| } | ||
|
|
||
| // InDays reports whether a recording belongs to the local days first through last. It | ||
| // answers for a list read, which asks for a run of days and has no period to answer from. | ||
| // HEY's series rows carry no end date, so a repeating row is kept unless it starts later. An | ||
| // all-day series is a date, so it is compared by its date, and a timed series by its instant. | ||
| func InDays(r generated.Recording, first, last time.Time, loc *time.Location) bool { | ||
| if r.StartsAt.IsZero() { | ||
| return true | ||
| } | ||
| if r.Recurring { | ||
| if r.AllDay { | ||
| return !utcDate(r.StartsAt).After(last) | ||
| } | ||
| return r.StartsAt.Before(civilMidnight(last.AddDate(0, 0, 1), loc)) | ||
| } | ||
| if r.AllDay { | ||
| startsOn := utcDate(r.StartsAt) | ||
| endsOn := startsOn | ||
| if r.EndsAt.After(r.StartsAt) { | ||
| endsOn = utcDate(r.EndsAt) | ||
| } | ||
| return !startsOn.After(last) && !endsOn.Before(first) | ||
| } | ||
| return Overlaps(r, civilMidnight(first, loc), civilMidnight(last.AddDate(0, 0, 1), loc)) | ||
| } | ||
|
|
||
| // SpanOf is the UTC days a period covers. HEY ends a period at midnight, which belongs to | ||
| // the day after, so that midnight does not count as a day of its own. | ||
| func SpanOf(period *generated.CalendarPeriod) Span { | ||
| first := utcDate(period.StartsAt) | ||
| last := utcDate(period.EndsAt) | ||
| endsAtMidnight := period.EndsAt.UTC().Equal(last) | ||
| if endsAtMidnight && period.EndsAt.After(period.StartsAt) { | ||
| last = last.AddDate(0, 0, -1) | ||
| } | ||
| if last.Before(first) { | ||
| last = first | ||
| } | ||
| return Span{First: first, Last: last} | ||
| } | ||
|
|
||
| // Overlaps reports whether a recording is inside [from, to). An instant belongs to the day | ||
| // it starts on, and a run belongs to every day it touches, but a run that ends at the | ||
| // start of a day is not on that day. | ||
| func Overlaps(r generated.Recording, from, to time.Time) bool { | ||
| if r.EndsAt.IsZero() || !r.EndsAt.After(r.StartsAt) { | ||
| return !r.StartsAt.Before(from) && r.StartsAt.Before(to) | ||
| } | ||
| return r.StartsAt.Before(to) && r.EndsAt.After(from) | ||
| } | ||
|
|
||
| // utcDate is the UTC day an instant falls on, as a midnight in UTC. HEY files an event under | ||
| // that day, whatever zone the reader keeps. | ||
| func utcDate(at time.Time) time.Time { | ||
| at = at.UTC() | ||
| return time.Date(at.Year(), at.Month(), at.Day(), 0, 0, 0, 0, time.UTC) | ||
| } | ||
|
|
||
| // civilMidnight is the start of a date in the reader's zone. The date is read off day as it | ||
| // is, so a UTC day maps to the same calendar date there. | ||
| func civilMidnight(day time.Time, loc *time.Location) time.Time { | ||
| return time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc) | ||
| } | ||
|
|
||
| // identity names an event so that the same one read under two days counts once. A recording | ||
| // with no id has none to go by, so its type, start and title stand in. | ||
| func identity(event generated.Recording) string { | ||
| if event.Id != 0 || event.OccurrenceId != "" { | ||
| return fmt.Sprintf("%d|%s", event.Id, event.OccurrenceId) | ||
| } | ||
| return fmt.Sprintf("%s|%d|%s", event.Type, event.StartsAt.UnixNano(), event.Title) | ||
| } | ||
|
|
||
| // Neighbours are the dates of the UTC days just outside the span. HEY can file an event of | ||
| // the span's local days under either of them. | ||
| func (s Span) Neighbours() (before, after string) { | ||
| return s.First.AddDate(0, 0, -1).Format(dateLayout), s.Last.AddDate(0, 0, 1).Format(dateLayout) | ||
| } | ||
|
|
||
| // Local is the span as a range of instants in the reader's zone, from the start of its | ||
| // first day up to the start of the day after its last. | ||
| func (s Span) Local(loc *time.Location) (from, to time.Time) { | ||
| return civilMidnight(s.First, loc), civilMidnight(s.Last.AddDate(0, 0, 1), loc) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| package calendarday | ||
|
|
||
| import ( | ||
| "slices" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/basecamp/hey-sdk/go/pkg/generated" | ||
| ) | ||
|
|
||
| func instant(t *testing.T, value string) time.Time { | ||
| t.Helper() | ||
| at, err := time.Parse(time.RFC3339, value) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| return at | ||
| } | ||
|
|
||
| func period(t *testing.T, first, last string, events ...generated.Recording) *generated.CalendarPeriod { | ||
| t.Helper() | ||
| return &generated.CalendarPeriod{ | ||
| StartsAt: instant(t, first+"T00:00:00Z"), | ||
| EndsAt: instant(t, last+"T23:59:59Z"), | ||
| Recordings: generated.CalendarRecordingsResponse{EventType: events}, | ||
| } | ||
| } | ||
|
|
||
| func TestSpanOf(t *testing.T) { | ||
| newYork, _ := time.LoadLocation("America/New_York") | ||
| cases := []struct { | ||
| name, starts, ends, last, before, after string | ||
| hours int | ||
| }{ | ||
| {"week", "2026-09-14T00:00:00Z", "2026-09-20T23:59:59Z", "2026-09-20", "2026-09-13", "2026-09-21", 7 * 24}, | ||
| {"end at midnight", "2026-09-14T00:00:00Z", "2026-09-21T00:00:00Z", "2026-09-20", "2026-09-13", "2026-09-21", 7 * 24}, | ||
| {"clock change", "2026-11-01T00:00:00Z", "2026-11-01T23:59:59Z", "2026-11-01", "2026-10-31", "2026-11-02", 25}, | ||
| } | ||
| for _, tc := range cases { | ||
| span := SpanOf(&generated.CalendarPeriod{StartsAt: instant(t, tc.starts), EndsAt: instant(t, tc.ends)}) | ||
| before, after := span.Neighbours() | ||
| from, to := span.Local(newYork) | ||
| if span.Last.Format(dateLayout) != tc.last || before != tc.before || after != tc.after || int(to.Sub(from).Hours()) != tc.hours { | ||
| t.Errorf("%s: last %s, neighbours %s %s, length %s", tc.name, span.Last.Format(dateLayout), before, after, to.Sub(from)) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestLocalEvents(t *testing.T) { | ||
| event := func(id int64, title, starts, ends string) generated.Recording { | ||
| r := generated.Recording{Id: id, Title: title, StartsAt: instant(t, starts)} | ||
| if ends != "" { | ||
| r.EndsAt = instant(t, ends) | ||
| } | ||
| return r | ||
| } | ||
| breakfast := event(1, "Breakfast", "2026-09-20T13:00:00Z", "2026-09-20T14:00:00Z") | ||
| dinner := event(2, "Dinner", "2026-09-21T00:50:00Z", "2026-09-21T02:40:00Z") | ||
| concert := event(3, "Concert", "2026-09-18T22:00:00Z", "2026-09-19T01:30:00Z") | ||
| flight := event(4, "Flight", "2026-09-20T23:00:00Z", "2026-09-21T05:00:00Z") | ||
| lateShow := event(5, "Late show", "2026-11-02T04:30:00Z", "2026-11-02T04:45:00Z") | ||
| earlyStart := event(6, "Early start", "2026-11-02T05:00:00Z", "2026-11-02T06:00:00Z") | ||
| closing := event(10, "Closing", "2026-09-19T02:00:00Z", "2026-09-19T04:00:00Z") | ||
| midnight := event(7, "Midnight", "2026-09-21T00:00:00Z", "") | ||
| holiday := event(8, "Holiday", "2026-09-20T00:00:00Z", "2026-09-20T00:00:00Z") | ||
| holiday.AllDay = true | ||
| nextHoliday := event(9, "Next holiday", "2026-09-21T00:00:00Z", "2026-09-21T00:00:00Z") | ||
| nextHoliday.AllDay = true | ||
| standup := generated.Recording{OccurrenceId: "7_2026-09-21", Title: "Standup", StartsAt: instant(t, "2026-09-21T14:00:00Z")} | ||
|
|
||
| cases := []struct { | ||
| name, zone, date string | ||
| main, before, after []generated.Recording | ||
| want []string | ||
| }{ | ||
| {"evening in New York", "America/New_York", "2026-09-20", []generated.Recording{breakfast}, nil, []generated.Recording{dinner}, []string{"Breakfast", "Dinner"}}, | ||
| {"next local day", "America/New_York", "2026-09-21", []generated.Recording{dinner}, nil, nil, nil}, | ||
| {"ended before the day", "America/New_York", "2026-09-19", []generated.Recording{concert}, nil, nil, nil}, | ||
| {"ended at local midnight", "America/New_York", "2026-09-19", []generated.Recording{closing}, nil, nil, nil}, | ||
| {"own local day", "America/New_York", "2026-09-18", []generated.Recording{concert}, nil, nil, []string{"Concert"}}, | ||
| {"east of UTC", "Asia/Tokyo", "2026-09-21", []generated.Recording{dinner}, nil, nil, []string{"Dinner"}}, | ||
| {"east of UTC, day before", "Asia/Tokyo", "2026-09-20", nil, nil, []generated.Recording{dinner}, nil}, | ||
| {"only from the day before", "UTC", "2026-09-19", nil, []generated.Recording{concert}, nil, []string{"Concert"}}, | ||
| {"clock change", "America/New_York", "2026-11-01", nil, nil, []generated.Recording{lateShow, earlyStart}, []string{"Late show"}}, | ||
| {"all-day from main only", "UTC", "2026-09-20", []generated.Recording{holiday}, nil, []generated.Recording{nextHoliday}, []string{"Holiday"}}, | ||
| {"same id twice", "UTC", "2026-09-21", []generated.Recording{flight}, []generated.Recording{flight}, nil, []string{"Flight"}}, | ||
| {"same occurrence twice", "UTC", "2026-09-21", []generated.Recording{standup}, []generated.Recording{standup}, nil, []string{"Standup"}}, | ||
| {"instant at the start", "UTC", "2026-09-21", nil, nil, []generated.Recording{midnight}, []string{"Midnight"}}, | ||
| {"instant at the end", "UTC", "2026-09-20", nil, nil, []generated.Recording{midnight}, nil}, | ||
| } | ||
| for _, tc := range cases { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| loc, _ := time.LoadLocation(tc.zone) | ||
| main := period(t, tc.date, tc.date, tc.main...) | ||
| neighbours := []*generated.CalendarPeriod{period(t, "2026-09-01", "2026-09-01", tc.before...), period(t, "2026-09-02", "2026-09-02", tc.after...)} | ||
| var got []string | ||
| for _, e := range LocalEvents(main, neighbours, loc) { | ||
| got = append(got, e.Title) | ||
| } | ||
| slices.Sort(got) | ||
| if !slices.Equal(got, tc.want) { | ||
| t.Errorf("got %v, want %v", got, tc.want) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestInDays(t *testing.T) { | ||
| day := func(date string) time.Time { return instant(t, date+"T00:00:00Z") } | ||
| newYork, _ := time.LoadLocation("America/New_York") | ||
| evening := generated.Recording{StartsAt: instant(t, "2026-09-21T00:50:00Z"), EndsAt: instant(t, "2026-09-21T02:40:00Z")} | ||
| cases := []struct { | ||
| name string | ||
| event generated.Recording | ||
| first string | ||
| last string | ||
| want bool | ||
| }{ | ||
| {"evening on its local day", evening, "2026-09-20", "2026-09-20", true}, | ||
| {"evening not on the next day", evening, "2026-09-21", "2026-09-21", false}, | ||
| {"series from a year before", generated.Recording{Recurring: true, StartsAt: instant(t, "2025-09-15T14:00:00Z")}, "2026-09-20", "2026-09-20", true}, | ||
| {"series from the day after", generated.Recording{Recurring: true, StartsAt: instant(t, "2026-09-21T14:00:00Z")}, "2026-09-20", "2026-09-20", false}, | ||
| {"series from the evening of the last day", generated.Recording{Recurring: true, StartsAt: instant(t, "2026-09-21T02:00:00Z")}, "2026-09-20", "2026-09-20", true}, | ||
| {"no start", generated.Recording{}, "2026-09-20", "2026-09-20", true}, | ||
| {"all-day on its date", generated.Recording{AllDay: true, StartsAt: day("2026-09-20"), EndsAt: day("2026-09-20")}, "2026-09-20", "2026-09-20", true}, | ||
| {"all-day outside", generated.Recording{AllDay: true, StartsAt: day("2026-09-20")}, "2026-09-21", "2026-09-22", false}, | ||
| {"multi-day all-day", generated.Recording{AllDay: true, StartsAt: day("2026-09-18"), EndsAt: day("2026-09-22")}, "2026-09-20", "2026-09-20", true}, | ||
| } | ||
| for _, tc := range cases { | ||
| if got := InDays(tc.event, day(tc.first), day(tc.last), newYork); got != tc.want { | ||
| t.Errorf("%s: InDays = %v, want %v", tc.name, got, tc.want) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestInDaysComparesAnAllDaySeriesByItsDate(t *testing.T) { | ||
| newYork, _ := time.LoadLocation("America/New_York") | ||
| tokyo, _ := time.LoadLocation("Asia/Tokyo") | ||
| cases := []struct { | ||
| name string | ||
| loc *time.Location | ||
| startsOn string | ||
| last string | ||
| want bool | ||
| }{ | ||
| {"New York, series from the day after", newYork, "2026-09-21", "2026-09-20", false}, | ||
| {"New York, series from the last day", newYork, "2026-09-20", "2026-09-20", true}, | ||
| {"Tokyo, series from the day after", tokyo, "2026-09-21", "2026-09-20", false}, | ||
| {"Tokyo, series from the last day", tokyo, "2026-09-20", "2026-09-20", true}, | ||
| } | ||
| for _, tc := range cases { | ||
| event := generated.Recording{Recurring: true, AllDay: true, StartsAt: instant(t, tc.startsOn+"T00:00:00Z")} | ||
| last := instant(t, tc.last+"T00:00:00Z") | ||
| if got := InDays(event, last, last, tc.loc); got != tc.want { | ||
| t.Errorf("%s: InDays = %v, want %v", tc.name, got, tc.want) | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.