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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -460,6 +460,17 @@ newest start time, not creation time, so use the ID returned by `hey event add`
edit or delete rather than choosing an event by its position in the list. A repeating event
lists once as the series it is stored as, plus a row for each day HEY has written out on its own.

A date given to `hey event list`, `hey event day` and `hey event week` is a day in your HEY
account's time zone. If the account has none, it is a day in the machine's zone, and the
command says so once on stderr. HEY reads a date as a UTC day, so the CLI reads the days
either side too and keeps the events that overlap the days you named. An event at 9:15 PM in
New York is on its own date, not on the next one. `hey event list` includes `--ends-on`,
lists an all-day event on the dates it names, and lists a repeating series as the one row HEY
stores, unless the series starts after the last day.

`hey event edit <id> <date>` looks for the event from the day before the date to the day
after it, so it finds an evening or early-morning event by the date it shows on.

`hey event day` and `hey event week` read a span the way HEY's own views draw it: a
repeating event is expanded into the occurrences that fall inside it, each carrying that
day's own times and an `occurrence_id`. A virtual occurrence carries the series in `id`
Expand Down
4 changes: 4 additions & 0 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,10 @@ The private note is shown formatted and edited as Markdown, the way `hey contact

Press Shift+C to open Calendar, then `c` to manage time track categories. Create a category with `n`, rename the selected category with Enter or `r`, and press `x` twice to delete it. Time tracks in a deleted category become uncategorized.

The day and week views show days of this machine's local time zone. A timed event belongs to
the local day it overlaps, and the app reads the day either side of the span to find it. An
all-day event stays on its own dates.

A new event's times are written in your HEY account's time zone, the one HEY's web app and
`hey event add` use: the Starts and Ends rows name it, and the event is saved in it. The
Calendar reads the zone each time it opens; if the account has none or it cannot be read,
Expand Down
197 changes: 197 additions & 0 deletions internal/calendarday/calendarday.go
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)
}
158 changes: 158 additions & 0 deletions internal/calendarday/calendarday_test.go
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},
Comment thread
armada877 marked this conversation as resolved.
}
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)
}
}
}
Loading
Loading