From 0800cb683f1c259f2e9825b4c806502e1d17aaa3 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 14:33:53 -0400 Subject: [PATCH 01/20] Move the check for zone names HEY can find into its own package loadEventZone lived in internal/cmd, where the TUI cannot reach it, and the TUI's event form checked a zone with time.LoadLocation alone, which answers names HEY cannot look up. timezone.Load is the same check, moved so both can use it, and it carries the embedded zone database with it. --- internal/cmd/events.go | 5 +- internal/cmd/events_zone.go | 76 ++-------------------------- internal/cmd/events_zone_test.go | 46 ----------------- internal/timezone/timezone.go | 80 ++++++++++++++++++++++++++++++ internal/timezone/timezone_test.go | 72 +++++++++++++++++++++++++++ 5 files changed, 158 insertions(+), 121 deletions(-) create mode 100644 internal/timezone/timezone.go create mode 100644 internal/timezone/timezone_test.go diff --git a/internal/cmd/events.go b/internal/cmd/events.go index 2998b5f5..5bb9150f 100644 --- a/internal/cmd/events.go +++ b/internal/cmd/events.go @@ -15,6 +15,7 @@ import ( "github.com/basecamp/hey-cli/internal/apierr" "github.com/basecamp/hey-cli/internal/output" "github.com/basecamp/hey-cli/internal/terminal" + "github.com/basecamp/hey-cli/internal/timezone" ) // recordingTypeEvent is how HEY names an event among the recordings a calendar holds. @@ -833,7 +834,7 @@ func (f *eventFields) validateExplicitScheduleFlags(cmd *cobra.Command) error { if f.timeZone == "" { return apierr.ErrUsageHint("--time-zone needs a time zone", "an IANA time zone name, for example America/New_York") } - if _, err := loadEventZone(f.timeZone); err != nil { + if _, err := timezone.Load(f.timeZone); err != nil { return errInvalidTimeZone(f.timeZone) } } @@ -1081,7 +1082,7 @@ func (f *eventFields) editZones(ctx context.Context, cmd *cobra.Command, event g // storedZone loads a zone an event was saved in. func storedZone(name string) (clockZone, error) { - loc, err := loadEventZone(name) + loc, err := timezone.Load(name) if err != nil { return clockZone{}, &apierr.Error{ Code: apierr.CodeUsage, diff --git a/internal/cmd/events_zone.go b/internal/cmd/events_zone.go index f0018935..2bf07d61 100644 --- a/internal/cmd/events_zone.go +++ b/internal/cmd/events_zone.go @@ -2,21 +2,12 @@ package cmd import ( "context" - "errors" "fmt" - "io/fs" - "os" - "path" - "slices" - "strings" "time" - // Every build carries the zone database, so an account's zone and --time-zone load the - // same way on Windows, Alpine, distroless images and a machine with old zone files. - _ "time/tzdata" - "github.com/basecamp/hey-cli/internal/apierr" "github.com/basecamp/hey-cli/internal/terminal" + "github.com/basecamp/hey-cli/internal/timezone" ) // eventNow is the clock an event's default date is read from, a seam for tests. @@ -25,67 +16,6 @@ var eventNow = time.Now // timeZoneHint is how every refusal about a zone says what to do instead. const timeZoneHint = "pass --time-zone with an IANA zone name, for example --time-zone America/New_York" -// errNotAZone is a name time.LoadLocation answers that HEY cannot look up. -var errNotAZone = errors.New("not an IANA zone name") - -// loadEventZone loads a zone the way HEY will look it up: by its exact IANA name. HEY stores -// a name it cannot find rather than refusing it, reads the event as UTC, and fails later in -// its own edit form, so anything HEY would not find is refused here instead. -// -// Go answers a few names HEY has no zone for: Local, the zone files that are not zones, and -// a path the file system tidies up, like America//New_York. And on a case-insensitive disk, -// as macOS has, it loads america/new_york from the file America/New_York, where HEY's lookup -// is case-sensitive; zoneFileSpelledAs catches that. -func loadEventZone(name string) (*time.Location, error) { - if !fs.ValidPath(name) || name == "Local" || name == "localtime" || name == "posixrules" || name == "Factory" || - strings.HasPrefix(name, "posix/") || strings.HasPrefix(name, "right/") { - return nil, errNotAZone - } - loc, err := time.LoadLocation(name) - if err != nil { - return nil, err - } - if !zoneFileSpelledAs(name) { - return nil, errNotAZone - } - return loc, nil -} - -// zoneFiles are the zone databases on disk that time.LoadLocation reads before the one -// compiled in, a seam for tests. The embedded database looks names up case-sensitively, so -// only these can answer a name in the wrong case. -var zoneFiles = func() []fs.FS { - dirs := []string{"/usr/share/zoneinfo", "/usr/share/lib/zoneinfo", "/usr/lib/locale/TZ", "/etc/zoneinfo", "/var/db/timezone/zoneinfo"} - if dir := os.Getenv("ZONEINFO"); dir != "" { - dirs = append([]string{dir}, dirs...) - } - files := make([]fs.FS, 0, len(dirs)) - for _, dir := range dirs { - files = append(files, os.DirFS(dir)) - } - return files -} - -// zoneFileSpelledAs is whether every zone file on disk that answers the name is spelled -// exactly that way. A name no file answers was read from the embedded database, which -// only answers exact names. -func zoneFileSpelledAs(name string) bool { - for _, files := range zoneFiles() { - if _, err := fs.Stat(files, name); err != nil { - continue - } - dir := "." - for part := range strings.SplitSeq(name, "/") { - entries, err := fs.ReadDir(files, dir) - if err != nil || !slices.ContainsFunc(entries, func(entry fs.DirEntry) bool { return entry.Name() == part }) { - return false - } - dir = path.Join(dir, part) - } - } - return true -} - // accountZone is the HEY account's time zone as the identity serves it, read at most once a // command and only when a clock time needs it. type accountZone struct { @@ -112,7 +42,7 @@ func (f *eventFields) accountTimeZone(ctx context.Context) (string, error) { // in most sandboxes, and not UTC, which is what HEY would read a zoneless time as. func (f *eventFields) writeZone(ctx context.Context) (string, *time.Location, error) { if f.timeZone != "" { - loc, err := loadEventZone(f.timeZone) + loc, err := timezone.Load(f.timeZone) if err != nil { return "", nil, errInvalidTimeZone(f.timeZone) } @@ -139,7 +69,7 @@ func (f *eventFields) writeZone(ctx context.Context) (string, *time.Location, er if name == "" { return "", nil, errNoAccountZone("your HEY account has no time zone set", nil) } - loc, err := loadEventZone(name) + loc, err := timezone.Load(name) if err != nil { return "", nil, errNoAccountZone(fmt.Sprintf("your HEY account's time zone %s is not one this build of hey knows", terminal.SanitizeLine(name)), err) } diff --git a/internal/cmd/events_zone_test.go b/internal/cmd/events_zone_test.go index dc8f5767..06cb3c7f 100644 --- a/internal/cmd/events_zone_test.go +++ b/internal/cmd/events_zone_test.go @@ -3,7 +3,6 @@ package cmd import ( "errors" "io" - "io/fs" "net/http" "net/http/httptest" "net/url" @@ -11,7 +10,6 @@ import ( "sync" "sync/atomic" "testing" - "testing/fstest" "time" "github.com/basecamp/hey-cli/internal/apierr" @@ -654,50 +652,6 @@ func TestEventsEditOccurrenceOfAZonedSeriesKeepsItsZone(t *testing.T) { } } -// foldedFS answers a name in any case, as the zone files on a case-insensitive disk do. -type foldedFS struct{ fstest.MapFS } - -func (f foldedFS) Open(name string) (fs.File, error) { - return f.MapFS.Open(f.stored(name)) -} - -func (f foldedFS) Stat(name string) (fs.FileInfo, error) { - return f.MapFS.Stat(f.stored(name)) -} - -func (f foldedFS) stored(name string) string { - for stored := range f.MapFS { - if strings.EqualFold(stored, name) { - return stored - } - } - return name -} - -// On macOS Go loads america/new_york from the file America/New_York, and HEY would store a -// name it cannot find. A name is only taken as the files spell it; one no file answers came -// from the embedded database, which answers exact names alone. -func TestZoneFileSpelledAs(t *testing.T) { - previous := zoneFiles - zoneFiles = func() []fs.FS { - return []fs.FS{foldedFS{fstest.MapFS{"America/New_York": {Data: []byte("TZif")}, "UTC": {Data: []byte("TZif")}}}} - } - t.Cleanup(func() { zoneFiles = previous }) - - for name, want := range map[string]bool{ - "America/New_York": true, - "america/new_york": false, - "America/NEW_YORK": false, - "UTC": true, - "utc": false, - "Europe/Lisbon": true, - } { - if got := zoneFileSpelledAs(name); got != want { - t.Errorf("zoneFileSpelledAs(%q) = %v, want %v", name, got, want) - } - } -} - // A clock time is placed where HEY places it. These are what ActiveSupport answers for // Time.zone.parse(clock).change(zone:), the way HEY reads a typed time: a time the clocks // skip moves an hour on, and a time they repeat is the daylight-saving one of the two, or diff --git a/internal/timezone/timezone.go b/internal/timezone/timezone.go new file mode 100644 index 00000000..11f419a0 --- /dev/null +++ b/internal/timezone/timezone.go @@ -0,0 +1,80 @@ +// Package timezone loads a time zone by the name HEY will look it up by. +// +// HEY stores a zone name it cannot find rather than refusing it, reads the event as UTC, +// and fails later in its own edit form, so every name this program sends as an event's +// zone — `hey event --time-zone`, the account's zone, the zone chosen on the TUI's event +// form — is checked here first, by the same rules. +package timezone + +import ( + "errors" + "io/fs" + "os" + "path" + "slices" + "strings" + "time" + + // Every build carries the zone database, so an account's zone and a chosen one load the + // same way on Windows, Alpine, distroless images and a machine with old zone files. + _ "time/tzdata" +) + +// ErrNotAZone is a name time.LoadLocation answers that HEY cannot look up. +var ErrNotAZone = errors.New("not an IANA zone name") + +// Load loads a zone the way HEY will look it up: by its exact IANA name. +// +// Go answers a few names HEY has no zone for: Local, the zone files that are not zones, and +// a path the file system tidies up, like America//New_York. And on a case-insensitive disk, +// as macOS has, it loads america/new_york from the file America/New_York, where HEY's lookup +// is case-sensitive; zoneFileSpelledAs catches that. +func Load(name string) (*time.Location, error) { + if !fs.ValidPath(name) || name == "Local" || name == "localtime" || name == "posixrules" || name == "Factory" || + strings.HasPrefix(name, "posix/") || strings.HasPrefix(name, "right/") { + return nil, ErrNotAZone + } + loc, err := time.LoadLocation(name) + if err != nil { + return nil, err + } + if !zoneFileSpelledAs(name) { + return nil, ErrNotAZone + } + return loc, nil +} + +// zoneFiles are the zone databases on disk that time.LoadLocation reads before the one +// compiled in, a seam for tests. The embedded database looks names up case-sensitively, so +// only these can answer a name in the wrong case. +var zoneFiles = func() []fs.FS { + dirs := []string{"/usr/share/zoneinfo", "/usr/share/lib/zoneinfo", "/usr/lib/locale/TZ", "/etc/zoneinfo", "/var/db/timezone/zoneinfo"} + if dir := os.Getenv("ZONEINFO"); dir != "" { + dirs = append([]string{dir}, dirs...) + } + files := make([]fs.FS, 0, len(dirs)) + for _, dir := range dirs { + files = append(files, os.DirFS(dir)) + } + return files +} + +// zoneFileSpelledAs is whether every zone file on disk that answers the name is spelled +// exactly that way. A name no file answers was read from the embedded database, which +// only answers exact names. +func zoneFileSpelledAs(name string) bool { + for _, files := range zoneFiles() { + if _, err := fs.Stat(files, name); err != nil { + continue + } + dir := "." + for part := range strings.SplitSeq(name, "/") { + entries, err := fs.ReadDir(files, dir) + if err != nil || !slices.ContainsFunc(entries, func(entry fs.DirEntry) bool { return entry.Name() == part }) { + return false + } + dir = path.Join(dir, part) + } + } + return true +} diff --git a/internal/timezone/timezone_test.go b/internal/timezone/timezone_test.go new file mode 100644 index 00000000..7741a039 --- /dev/null +++ b/internal/timezone/timezone_test.go @@ -0,0 +1,72 @@ +package timezone + +import ( + "errors" + "io/fs" + "strings" + "testing" + "testing/fstest" +) + +// Load takes the names HEY looks up — canonical names and links alike — and refuses the ones +// Go answers that HEY has no zone for, before anything is sent. +func TestLoad(t *testing.T) { + for _, name := range []string{"America/New_York", "America/Indiana/Indianapolis", "US/Eastern", "Etc/GMT+5", "UTC", "Europe/Kyiv"} { + if _, err := Load(name); err != nil { + t.Errorf("Load(%q) = %v, want the zone", name, err) + } + } + for _, name := range []string{"", "Local", "localtime", "posixrules", "Factory", "posix/Europe/Zagreb", "right/Europe/Zagreb", + "America//New_York", "/America/New_York", "Eastern Time (US & Canada)", "Mars/Olympus_Mons"} { + if _, err := Load(name); err == nil { + t.Errorf("Load(%q) loaded a zone HEY cannot look up", name) + } + } + if _, err := Load("Local"); !errors.Is(err, ErrNotAZone) { + t.Errorf("Load(Local) = %v, want ErrNotAZone", err) + } +} + +// foldedFS answers a name in any case, as the zone files on a case-insensitive disk do. +type foldedFS struct{ fstest.MapFS } + +func (f foldedFS) Open(name string) (fs.File, error) { + return f.MapFS.Open(f.stored(name)) +} + +func (f foldedFS) Stat(name string) (fs.FileInfo, error) { + return f.MapFS.Stat(f.stored(name)) +} + +func (f foldedFS) stored(name string) string { + for stored := range f.MapFS { + if strings.EqualFold(stored, name) { + return stored + } + } + return name +} + +// On macOS Go loads america/new_york from the file America/New_York, and HEY would store a +// name it cannot find. A name is only taken as the files spell it; one no file answers came +// from the embedded database, which answers exact names alone. +func TestZoneFileSpelledAs(t *testing.T) { + previous := zoneFiles + zoneFiles = func() []fs.FS { + return []fs.FS{foldedFS{fstest.MapFS{"America/New_York": {Data: []byte("TZif")}, "UTC": {Data: []byte("TZif")}}}} + } + t.Cleanup(func() { zoneFiles = previous }) + + for name, want := range map[string]bool{ + "America/New_York": true, + "america/new_york": false, + "America/NEW_YORK": false, + "UTC": true, + "utc": false, + "Europe/Lisbon": true, + } { + if got := zoneFileSpelledAs(name); got != want { + t.Errorf("zoneFileSpelledAs(%q) = %v, want %v", name, got, want) + } + } +} From 360a4d9ba4d9bd2e90d0c3ebc92272d7b017ed4b Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 14:34:00 -0400 Subject: [PATCH 02/20] Write a new event from the TUI in the HEY account's time zone The event form opened on Local: it read a typed time on the machine's clock and sent it as UTC with no zone, so a TUI event was saved zoneless while hey event add and HEY's web app read the same typed time in the account's zone. A new event now opens on the account's zone, named on the Starts and Ends rows and sent with the write. The zone comes from the identity the Calendar already reads each time it opens, so a zone changed on the web is picked up the next time it is entered. An account with no zone, a failed read, or a name HEY would not look up leaves the form on Local rather than refusing to open it. Local and every other zone stay in the list. An edit keeps the event's own terms: a zoned event keeps its zone, and a timed event saved without one stays on Local and goes back zoneless. An all-day event given a time takes the account's zone, as hey event edit gives it. The next whole hour a new event starts on is the account clock's, on the day in view, and it is a whole hour of that clock: in a half-hour zone it used to be a whole UTC hour, half past on the reader's clock. HEY keeps a zone for both ends or neither, and names the one given for both, so an end moved back to Local beside a zoned one is now written on the other end's clock rather than sent as a UTC time HEY would read in that zone. Zones on the form are checked with timezone.Load, the rules hey event --time-zone uses. --- docs/tui.md | 7 + internal/tui/calendar.go | 13 +- internal/tui/datetime.go | 6 +- internal/tui/event_form.go | 103 +++++++--- internal/tui/event_form_test.go | 10 +- internal/tui/event_form_zone_test.go | 297 +++++++++++++++++++++++++++ 6 files changed, 399 insertions(+), 37 deletions(-) create mode 100644 internal/tui/event_form_zone_test.go diff --git a/docs/tui.md b/docs/tui.md index bb56ed4a..5e2e8511 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -146,4 +146,11 @@ The private note is shown formatted and edited as Markdown, the way `hey contact Press Shift+C to open Calendar, then `c` to manage time track categories. Create a category with `n`, rename the selected category with Enter or `r`, and press `x` twice to delete it. Time tracks in a deleted category become uncategorized. +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, +the form opens on `Local` instead. The zone list still offers `Local` (your machine's clock, +saved without a zone) and every other zone. An edit keeps the event's own zone, and an event +saved without one stays on `Local`; an all-day event given a time takes the account's zone. + In Calendar, press `a` to create a habit. Habits visible in the current calendar range can be selected with `[` and `]`, edited with `e`, and deleted by pressing `x` twice. Habit forms use Tab to move between fields and Ctrl+S to save. diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index 4ecbbad8..79a91e72 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -242,6 +242,9 @@ type yearLoadedMsg struct { type identityLoadedMsg struct { firstWeekDay time.Weekday use24Hour bool + // timeZone is the account's zone as the identity serves it, and empty when it has none + // or the read failed. + timeZone string } // calendarSettingsSavedMsg is the settings form's write landing — or not. @@ -391,6 +394,12 @@ type calendarView struct { // it, and the settings form writes it back. use24Hour bool + // accountZone is the zone HEY's web app reads a typed time in, as the identity served it + // when this section was last entered, and what a new event's times are written in. It is + // read with the rest of the identity rather than kept anywhere longer-lived, so a zone + // changed on the web is picked up the next time the calendar is opened. + accountZone string + // now is the clock the calendar anchors on. It is read on every fetch and // every render, so a TUI left open overnight moves to the new day instead of // fetching around the day it started on while the grid highlights today. @@ -552,6 +561,7 @@ func (v *calendarView) Update(msg tea.Msg) (tea.Cmd, bool) { case identityLoadedMsg: v.firstWeekDay = msg.firstWeekDay v.use24Hour = msg.use24Hour + v.accountZone = msg.timeZone v.rebuildView() return nil, true @@ -2081,7 +2091,7 @@ func (v *calendarView) startEventForm(mode eventFormMode, event Recording) tea.C return notifyError("Cannot add an event", errNoCalendars) } v.editing = event - v.eventForm = newEventForm(mode, event, v.day(), fileable, v.newEventCalendarID(fileable), v.vc.styles) + v.eventForm = newEventForm(mode, event, v.day(), fileable, v.newEventCalendarID(fileable), v.accountZone, v.vc.styles) // An edit is handed what the event already carries, and this is load-bearing rather than a // courtesy: HEY clears the notes, location, link and attached email on any write that @@ -2423,6 +2433,7 @@ func (v *calendarView) fetchIdentity() tea.Cmd { return identityLoadedMsg{ firstWeekDay: time.Weekday(wd), use24Hour: identity.TimeFormat == string(hey.TimeFormatTwentyFourHour), + timeZone: identity.TimeZone, } } } diff --git a/internal/tui/datetime.go b/internal/tui/datetime.go index d3fbc4f8..aa017700 100644 --- a/internal/tui/datetime.go +++ b/internal/tui/datetime.go @@ -131,8 +131,8 @@ func (p *dateTimePicker) zoneName() string { func (p *dateTimePicker) moment() (time.Time, bool) { in := time.Local if name := p.zoneName(); name != "" { - zone, err := time.LoadLocation(name) - if err != nil { + zone, ok := loadEventZone(name) + if !ok { return time.Time{}, false } in = zone @@ -158,7 +158,7 @@ func (p *dateTimePicker) problem() string { return "Time must be HH:MM" } if name := p.zoneName(); name != "" { - if _, err := time.LoadLocation(name); err != nil { + if _, ok := loadEventZone(name); !ok { return "That is not a time zone" } } diff --git a/internal/tui/event_form.go b/internal/tui/event_form.go index aa15f845..9bb58b03 100644 --- a/internal/tui/event_form.go +++ b/internal/tui/event_form.go @@ -18,6 +18,7 @@ import ( hey "github.com/basecamp/hey-sdk/go/pkg/hey" "github.com/basecamp/hey-cli/internal/terminal" + "github.com/basecamp/hey-cli/internal/timezone" ) type eventFormMode int @@ -139,7 +140,12 @@ type eventForm struct { // the one this reader filed on last, because somebody who keeps a work calendar and a // personal one files on the same one all week, or else the one HEY files on by default (see // calendarView.newEventCalendarID). An edit opens on the event's own calendar instead. -func newEventForm(mode eventFormMode, event Recording, on time.Time, calendars []Calendar, calendarID int64, styles styles) *eventForm { +// +// accountZone is the HEY account's time zone, the one HEY's web app and `hey event add` read +// a typed time in, and a new event's times are written in it and sent with its name. Empty, +// or a name HEY could not look up, leaves the form on Local — the reader can see the choice +// on the form, which is reason enough not to refuse to open it. +func newEventForm(mode eventFormMode, event Recording, on time.Time, calendars []Calendar, calendarID int64, accountZone string, styles styles) *eventForm { form := &eventForm{ mode: mode, eventID: event.ID, @@ -164,10 +170,27 @@ func newEventForm(mode eventFormMode, event Recording, on time.Time, calendars [ form.calendar = indexOfCalendarID(calendars, calendarID) } + // Which clock each moment is written on. An event saved with zones of its own is shown on + // the clock it was written on — 09:00 in Madrid stays 09:00 in Madrid, wherever it is being + // read. A timed event saved without one stays on Local, the reader's own clock, which is + // the one the calendar draws it on, and goes back as UTC with no zone, as it came: an edit + // does not zone an event nobody asked it to. Everything else — a new event, and an all-day + // one that may yet be given a time — is written in the account's zone, as `hey event` + // writes it. + account, accountLoc := usableZone(accountZone) + startZone, endZone := account, account + if mode == eventFormEdit && !event.AllDay { + startZone, endZone = event.StartsAtZone, event.EndsAtZone + } + // An edit shows the event's own times; a new event is offered the next whole hour for - // an hour. An event missing either time falls back to the same guess rather than to a - // blank field. - starts := nextWholeHour(on) + // an hour, on the clock it is written on. An event missing either time falls back to the + // same guess rather than to a blank field. + clock := on.Location() + if accountLoc != nil { + clock = accountLoc + } + starts := newEventStart(on, clock) if mode == eventFormEdit && !event.Starts().IsZero() { starts = event.Starts() } @@ -176,18 +199,24 @@ func newEventForm(mode eventFormMode, event Recording, on time.Time, calendars [ ends = event.Ends() } - // An event saved with zones of its own is shown on the clock it was written on — 09:00 in - // Madrid stays 09:00 in Madrid, wherever it is being read. Everything else is the reader's - // own clock, which the picker calls Local and sends as UTC. form.starts = newDateTimePicker(inZoneNamed(starts, event.StartsAtZone), form.allDay) form.ends = newDateTimePicker(inZoneNamed(ends, event.EndsAtZone), form.allDay) - if !form.allDay { - form.starts.setZoneName(event.StartsAtZone) - form.ends.setZoneName(event.EndsAtZone) - } + form.starts.setZoneName(startZone) + form.ends.setZoneName(endZone) return form } +// usableZone is the account's zone when HEY can look it up by that name, and nothing when it +// cannot: an account with no zone set, a read that failed and a name this build does not know +// all open the form on Local instead. +func usableZone(name string) (string, *time.Location) { + zone, ok := loadEventZone(name) + if !ok { + return "", nil + } + return name, zone +} + func eventInput(placeholder string, width int) textinput.Model { input := newTextInput() input.Prompt = "" @@ -271,11 +300,13 @@ func inZoneNamed(at time.Time, name string) time.Time { return at } +// loadEventZone loads a zone by the rules `hey event --time-zone` uses, which are the ones HEY +// looks a zone up by: a name HEY would not find is no zone at all, even where Go answers it. func loadEventZone(name string) (*time.Location, bool) { if name == "" { return nil, false } - zone, err := time.LoadLocation(name) + zone, err := timezone.Load(name) if err != nil { return nil, false } @@ -318,14 +349,17 @@ func zoneMatchesLocal(name string) bool { return named == local } -// nextWholeHour is where a new event starts when the reader has not said: the next hour on -// the clock, so a form opened at 09:41 offers 10:00 rather than 09:41. -func nextWholeHour(at time.Time) time.Time { - rounded := at.Truncate(time.Hour) - if rounded.Before(at) { - rounded = rounded.Add(time.Hour) - } - return rounded +// newEventStart is where a new event starts when the reader has not said: on the day they are +// looking at, at the next whole hour on the clock the event is written on, so a form opened at +// 09:41 offers 10:00 rather than 09:41. The day is the one on screen even where that clock is +// already on another — the reader chose the day by looking at it. +func newEventStart(on time.Time, zone *time.Location) time.Time { + clock := on.In(zone) + hour := clock.Hour() + if clock.Minute() != 0 || clock.Second() != 0 || clock.Nanosecond() != 0 { + hour++ + } + return time.Date(on.Year(), on.Month(), on.Day(), hour, 0, 0, 0, zone) } // indexOfCalendar finds the calendar an event is filed on. The id is the answer where the @@ -495,9 +529,10 @@ type eventFormValues struct { StartTime string EndTime string // StartTimeZone and EndTimeZone are the zones the clock times above are written in, and - // they are empty for a moment left on Local — that says UTC, which is how HEY reads a time + // they are empty for an event left on Local — that says UTC, which is how HEY reads a time // nobody named a zone for. Sending them empty on an update is not the same as leaving // them out: it clears the zones the event had, which is what moving back to Local means. + // One end on Local beside a zoned one is written in the other's zone; see wireMoment. StartTimeZone string EndTimeZone string // Reminders is every notice period chosen, and HEY reads it as the whole set: an update @@ -535,15 +570,16 @@ type eventFormValues struct { // request that is UTC when it was told nothing — ApiRequest#set_utc_timezone sets it. So each // moment has two honest ways to say when it is, and the form uses both: // +// With a zone chosen — the account's, which a new event opens on, or any other — the time goes +// as the reader wrote it, next to the zone they wrote it in, and HEY places it on that clock +// itself and stores the zone along with the event. That is what an event should keep when the +// reader travels, and it is the only way to say an event starts in one zone and ends in +// another. +// // Left on Local, the time is converted to UTC here and no zone is named. Converting needs no // name and is exact — 08:00 in Zagreb is one instant whatever anybody calls the zone — which // matters because Go will not always give a name for the local zone at all. // -// With a zone chosen, the time goes as the reader wrote it, next to the zone they wrote it in, -// and HEY stores the zone along with the event. That is what an event should keep when the -// reader travels, and it is the only way to say an event starts in one zone and ends in -// another. -// // An all-day event is neither: it is sent as the date typed, unconverted, because it is a // calendar date rather than a moment and shifting it would move a birthday. func (f *eventForm) values() eventFormValues { @@ -568,15 +604,20 @@ func (f *eventForm) values() eventFormValues { if f.allDay { return values } - values.StartsAt, values.StartTime, values.StartTimeZone = wireMoment(f.starts) - values.EndsAt, values.EndTime, values.EndTimeZone = wireMoment(f.ends) + values.StartsAt, values.StartTime, values.StartTimeZone = wireMoment(f.starts, f.ends.zoneName()) + values.EndsAt, values.EndTime, values.EndTimeZone = wireMoment(f.ends, f.starts.zoneName()) return values } // wireMoment is one moment as HEY should read it. A field the reader is still typing does not // parse; validate refuses the save before that matters, and until then the strings as typed // are the honest answer. -func wireMoment(p *dateTimePicker) (date, clock, zone string) { +// +// HEY keeps a zone for both ends of an event or for neither, and a write naming one zone is +// given it for both. So a moment left on Local beside one with a zone — a new event whose end +// the reader moved back to Local, say — cannot go as UTC: HEY would read it on the other end's +// clock. It is written on that clock instead, which is the same instant. +func wireMoment(p *dateTimePicker, other string) (date, clock, zone string) { if name := p.zoneName(); name != "" { return p.date(), p.clock(), name } @@ -584,6 +625,10 @@ func wireMoment(p *dateTimePicker) (date, clock, zone string) { if !ok { return p.date(), p.clock(), "" } + if loc, ok := loadEventZone(other); ok { + at = at.In(loc) + return at.Format("2006-01-02"), at.Format("15:04"), other + } return at.UTC().Format("2006-01-02"), at.UTC().Format("15:04"), "" } diff --git a/internal/tui/event_form_test.go b/internal/tui/event_form_test.go index 707c9303..5bafec08 100644 --- a/internal/tui/event_form_test.go +++ b/internal/tui/event_form_test.go @@ -28,7 +28,7 @@ func eventFormCalendars() []Calendar { // newTestEventForm is the form as the calendar view opens it, with nothing remembered about // which calendar was filed on last. func newTestEventForm(mode eventFormMode, event Recording, on time.Time, calendars []Calendar) *eventForm { - return newEventForm(mode, event, on, calendars, 0, newStyles()) + return newEventForm(mode, event, on, calendars, 0, "", newStyles()) } // focusOn puts the form on a field the way tab would, so a picker underneath is focused too. @@ -259,21 +259,21 @@ func TestEventFormNamesTheAccountACalendarBelongsTo(t *testing.T) { func TestNewEventFormOpensOnTheRememberedCalendar(t *testing.T) { on := time.Date(2026, 8, 20, 9, 0, 0, 0, time.Local) - form := newEventForm(eventFormCreate, Recording{}, on, eventFormCalendars(), 486532, newStyles()) + form := newEventForm(eventFormCreate, Recording{}, on, eventFormCalendars(), 486532, "", newStyles()) if got := form.values().CalendarID; got != 486532 { t.Errorf("calendar = %d, want the remembered one", got) } // A calendar the reader has since lost access to is not in the list, and the form falls // back to the first rather than filing somewhere HEY would refuse. - form = newEventForm(eventFormCreate, Recording{}, on, eventFormCalendars(), 999999, newStyles()) + form = newEventForm(eventFormCreate, Recording{}, on, eventFormCalendars(), 999999, "", newStyles()) if got := form.values().CalendarID; got != 240334 { t.Errorf("calendar = %d, want the first offered", got) } // An edit opens on the event's own calendar whatever was remembered. event := Recording{ID: 99, CalendarID: 240334, Type: "Calendar::Event"} - form = newEventForm(eventFormEdit, event, on, eventFormCalendars(), 486532, newStyles()) + form = newEventForm(eventFormEdit, event, on, eventFormCalendars(), 486532, "", newStyles()) if got := form.values().CalendarID; got != 240334 { t.Errorf("calendar = %d, want the one the event is on", got) } @@ -936,6 +936,8 @@ func calendarWithEventServer(t *testing.T) (*calendarView, *recordedEventRequest _, _ = io.WriteString(w, `{"id":1,"type":"Calendar::Event","title":"Renamed"}`) case req.Method == http.MethodDelete && strings.HasPrefix(req.URL.Path, "/calendar/events/"): w.WriteHeader(http.StatusNoContent) + case req.Method == http.MethodGet && req.URL.Path == "/identity.json": + _, _ = io.WriteString(w, `{"id":7,"first_week_day":0,"time_zone":"America/Indiana/Indianapolis"}`) default: _, _ = io.WriteString(w, `{"starts_at":"2026-08-20T00:00:00Z","ends_at":"2026-08-20T23:59:59Z","kind":"day","recordings":{}}`) } diff --git a/internal/tui/event_form_zone_test.go b/internal/tui/event_form_zone_test.go new file mode 100644 index 00000000..7d1ed02b --- /dev/null +++ b/internal/tui/event_form_zone_test.go @@ -0,0 +1,297 @@ +package tui + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + hey "github.com/basecamp/hey-sdk/go/pkg/hey" +) + +// These tests never set time.Local: every moment they build is in a zone named here, so +// what they assert about the account's zone holds whichever zone the machine running them +// keeps. + +const indianapolis = "America/Indiana/Indianapolis" + +func mustZone(t *testing.T, name string) *time.Location { + t.Helper() + zone, err := time.LoadLocation(name) + if err != nil { + t.Fatalf("load %s: %v", name, err) + } + return zone +} + +func newAccountZoneForm(mode eventFormMode, event Recording, on time.Time, accountZone string) *eventForm { + return newEventForm(mode, event, on, eventFormCalendars(), 0, accountZone, newStyles()) +} + +// A new event is written in the HEY account's zone — the one HEY's web app and `hey event add` +// read a typed time in — shown by its name and sent with it, so the three agree on when the +// event is. The next whole hour is the next one on the account's clock. +func TestNewEventFormOpensOnTheAccountZone(t *testing.T) { + // 09:41 in Madrid is 03:41 in Indianapolis. + on := time.Date(2026, 10, 14, 9, 41, 0, 0, mustZone(t, "Europe/Madrid")) + form := newAccountZoneForm(eventFormCreate, Recording{}, on, indianapolis) + + if got := stripANSI(form.view()); strings.Count(got, indianapolis) != 2 { + t.Errorf("the form shows %q, want both moments on the account's zone", got) + } + values := form.values() + if values.StartsAt != "2026-10-14" || values.StartTime != "04:00" { + t.Errorf("start = %q %q, want 2026-10-14 04:00 on the account's clock", values.StartsAt, values.StartTime) + } + if values.EndsAt != "2026-10-14" || values.EndTime != "05:00" { + t.Errorf("end = %q %q, want 2026-10-14 05:00 on the account's clock", values.EndsAt, values.EndTime) + } + if values.StartTimeZone != indianapolis || values.EndTimeZone != indianapolis { + t.Errorf("zones = %q → %q, want the account's named for both", values.StartTimeZone, values.EndTimeZone) + } + if got := form.validate(); got != "Name is required" { + t.Errorf("validate = %q, want only the missing name", got) + } +} + +// The day is the one on screen, even where the account's clock has already moved on to the +// next: a reader looking at the 14th late in the evening gets an event on the 14th. +func TestNewEventFormKeepsTheDayInViewOnTheAccountsClock(t *testing.T) { + // 17:30 in Los Angeles on the 14th is 02:30 in Madrid on the 15th. + on := time.Date(2026, 10, 14, 17, 30, 0, 0, mustZone(t, "America/Los_Angeles")) + form := newAccountZoneForm(eventFormCreate, Recording{}, on, "Europe/Madrid") + + values := form.values() + if values.StartsAt != "2026-10-14" || values.StartTime != "03:00" { + t.Errorf("start = %q %q, want 03:00 on the 14th", values.StartsAt, values.StartTime) + } +} + +// Half-hour zones have whole hours of their own: the next one in Kolkata is on the hour there, +// not half past, which is where a whole UTC hour lands. +func TestNewEventFormTakesTheNextWholeHourOfAHalfHourZone(t *testing.T) { + // 09:41 UTC is 15:11 in Kolkata. + on := time.Date(2026, 10, 14, 9, 41, 0, 0, time.UTC) + form := newAccountZoneForm(eventFormCreate, Recording{}, on, "Asia/Kolkata") + + if values := form.values(); values.StartTime != "16:00" || values.EndTime != "17:00" { + t.Errorf("times = %q → %q, want 16:00 → 17:00 in Kolkata", values.StartTime, values.EndTime) + } +} + +// An account with no zone, a read that failed and a name HEY would not look up all leave the +// form on Local rather than refusing to open it: the choice is on screen for the reader. +func TestNewEventFormOpensOnLocalWithoutAUsableAccountZone(t *testing.T) { + on := time.Date(2026, 10, 14, 9, 41, 0, 0, time.Local) + for _, accountZone := range []string{"", "Mars/Olympus_Mons", "Local", "America//New_York", "Eastern Time (US & Canada)"} { + form := newAccountZoneForm(eventFormCreate, Recording{}, on, accountZone) + + if form.starts.zoneName() != "" || form.ends.zoneName() != "" { + t.Errorf("%q: zones = %q → %q, want Local", accountZone, form.starts.zoneName(), form.ends.zoneName()) + } + if got := stripANSI(form.view()); strings.Count(got, localZoneLabel) != 2 { + t.Errorf("%q: the form shows %q, want both moments on Local", accountZone, got) + } + if values := form.values(); values.StartTimeZone != "" || values.EndTimeZone != "" { + t.Errorf("%q: zones on the wire = %q → %q, want none", accountZone, values.StartTimeZone, values.EndTimeZone) + } + } +} + +// Local and the rest of the list are still there to choose, and choosing Local sends the time +// as UTC with no zone, as it always has. +func TestChoosingLocalOnANewEventSendsUTC(t *testing.T) { + on := time.Date(2026, 10, 14, 9, 41, 0, 0, mustZone(t, "Europe/Madrid")) + form := newAccountZoneForm(eventFormCreate, Recording{}, on, indianapolis) + + if choices := form.starts.choices; len(choices) < 3 || choices[0] != localZoneLabel { + t.Fatalf("choices = %v, want Local first and the full list after it", choices) + } + for _, picker := range []*dateTimePicker{form.starts, form.ends} { + picker.focusField(dateTimeFieldZone) + typeInto(t, picker, "local") + picker.handleKey(keyPress("enter")) + } + if form.starts.zoneName() != "" || form.ends.zoneName() != "" { + t.Fatalf("zones = %q → %q, want Local chosen", form.starts.zoneName(), form.ends.zoneName()) + } + + // The clock on the form is now read on the machine's, and goes as that instant in UTC. + starts, _ := time.ParseInLocation("2006-01-02 15:04", form.starts.date()+" "+form.starts.clock(), time.Local) + values := form.values() + if values.StartsAt != starts.UTC().Format("2006-01-02") || values.StartTime != starts.UTC().Format("15:04") { + t.Errorf("start = %q %q, want %s", values.StartsAt, values.StartTime, starts.UTC()) + } + if values.StartTimeZone != "" || values.EndTimeZone != "" { + t.Errorf("zones = %q → %q, want none — a moment on Local goes as UTC", values.StartTimeZone, values.EndTimeZone) + } +} + +// HEY keeps a zone for both ends or neither, so one end moved to Local beside the account's +// zone is written on the account's clock: the same instant, on the clock HEY will read it on. +func TestOneEndOnLocalIsWrittenInTheOtherEndsZone(t *testing.T) { + on := time.Date(2026, 10, 14, 9, 41, 0, 0, mustZone(t, "Europe/Madrid")) + form := newAccountZoneForm(eventFormCreate, Recording{}, on, indianapolis) + form.ends.setZoneName("") + + ends, _ := time.ParseInLocation("2006-01-02 15:04", form.ends.date()+" "+form.ends.clock(), time.Local) + ends = ends.In(mustZone(t, indianapolis)) + values := form.values() + if values.EndTimeZone != indianapolis || values.EndsAt != ends.Format("2006-01-02") || values.EndTime != ends.Format("15:04") { + t.Errorf("end = %q %q %q, want %s", values.EndsAt, values.EndTime, values.EndTimeZone, ends) + } + if values.StartTimeZone != indianapolis || values.StartTime != "04:00" { + t.Errorf("start = %q %q, want 04:00 in the account's zone", values.StartTime, values.StartTimeZone) + } +} + +// An edit keeps the event's own terms rather than the account's, as `hey event edit` does: a +// zoned event keeps its zone, and a timed event saved without one stays on Local and goes back +// as UTC with no zone. An all-day event has no clock of its own, so given a time it takes the +// account's zone, as a new event would. +func TestEditingAnEventKeepsItsOwnZoneOverTheAccounts(t *testing.T) { + on := time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC) + + zoned := Recording{ + ID: 99, Title: "Product review", Type: "Calendar::Event", + StartsAt: time.Date(2026, 8, 20, 7, 0, 0, 0, time.UTC), + EndsAt: time.Date(2026, 8, 20, 8, 0, 0, 0, time.UTC), + StartsAtZone: "Europe/Madrid", EndsAtZone: "Europe/Madrid", + } + form := newAccountZoneForm(eventFormEdit, zoned, on, indianapolis) + values := form.values() + if values.StartTimeZone != "Europe/Madrid" || values.EndTimeZone != "Europe/Madrid" || + values.StartTime != "09:00" || values.EndTime != "10:00" { + t.Errorf("zoned edit = %s %s → %s %s, want 09:00 → 10:00 in Madrid", + values.StartTime, values.StartTimeZone, values.EndTime, values.EndTimeZone) + } + + zoneless := Recording{ + ID: 100, Title: "Standup", Type: "Calendar::Event", + StartsAt: time.Date(2026, 8, 20, 13, 30, 0, 0, time.UTC), + EndsAt: time.Date(2026, 8, 20, 13, 45, 0, 0, time.UTC), + } + form = newAccountZoneForm(eventFormEdit, zoneless, on, indianapolis) + values = form.values() + if values.StartTimeZone != "" || values.EndTimeZone != "" { + t.Errorf("zoneless edit zones = %q → %q, want none", values.StartTimeZone, values.EndTimeZone) + } + if values.StartsAt != "2026-08-20" || values.StartTime != "13:30" || values.EndTime != "13:45" { + t.Errorf("zoneless edit = %s %s → %s, want its own instants in UTC", values.StartsAt, values.StartTime, values.EndTime) + } + + allDay := Recording{ + ID: 101, Title: "Offsite", Type: "Calendar::Event", AllDay: true, + StartsAt: time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC), + EndsAt: time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC), + } + form = newAccountZoneForm(eventFormEdit, allDay, on, indianapolis) + form.setAllDay(false) + values = form.values() + if values.StartTimeZone != indianapolis || values.EndTimeZone != indianapolis || values.StartsAt != "2026-08-20" { + t.Errorf("all-day made timed = %s %s → %s, want the day kept in the account's zone", + values.StartsAt, values.StartTimeZone, values.EndTimeZone) + } +} + +// The order of the two moments is read on the clock each was chosen on: 10:00 in Madrid is +// before 09:00 in Indianapolis, and 09:00 in Madrid is before 08:00 in Indianapolis. +func TestEventFormOrdersMomentsOnTheirChosenClocks(t *testing.T) { + on := time.Date(2026, 10, 14, 9, 41, 0, 0, mustZone(t, "Europe/Madrid")) + + form := newAccountZoneForm(eventFormCreate, Recording{}, on, indianapolis) + form.title.SetValue("Design review") + form.starts.timeInput.SetValue("09:00") + form.ends.setZoneName("Europe/Madrid") + form.ends.timeInput.SetValue("10:00") + if got := form.validate(); got != "The end is before the start" { + t.Errorf("validate = %q, want the end refused as before the start", got) + } + + form = newAccountZoneForm(eventFormCreate, Recording{}, on, indianapolis) + form.title.SetValue("Design review") + form.starts.setZoneName("Europe/Madrid") + form.starts.timeInput.SetValue("09:00") + form.ends.timeInput.SetValue("08:00") + if got := form.validate(); got != "" { + t.Errorf("validate = %q, want 09:00 Madrid → 08:00 Indianapolis taken", got) + } + + // And a zone is one when HEY would look it up by that name, not merely when Go loads it. + form.starts.setZoneName("America//New_York") + if got := form.validate(); got != "Starts — That is not a time zone" { + t.Errorf("validate = %q, want a name HEY cannot look up refused", got) + } +} + +// The calendar reads the account's zone with the rest of the identity when it opens, and a new +// event's write names it. +func TestNewEventSendsTheAccountZoneTheIdentityServed(t *testing.T) { + v, recorded := calendarWithEventServer(t) + v.Update(v.fetchIdentity()()) + + v.HandleContentKey(keyPress("a")) + if v.eventForm == nil { + t.Fatal("a did not open the event form") + } + v.eventForm.title.SetValue("Design review") + wantDate, wantClock := v.eventForm.starts.date(), v.eventForm.starts.clock() + + cmd := v.HandleContentKey(keyPress("ctrl+s")) + if cmd == nil { + t.Fatal("ctrl+s did not save") + } + if msg, ok := cmd().(calendarMutationMsg); !ok || msg.err != nil { + t.Fatalf("save = %T %v", msg, msg.err) + } + + requests, bodies := recorded.snapshot() + if len(requests) != 2 || requests[1] != "POST /calendar/events.json" { + t.Fatalf("requests = %v, want the identity read and then the create", requests) + } + form, err := url.ParseQuery(bodies[1]) + if err != nil { + t.Fatalf("body is not form-encoded: %v", err) + } + for field, want := range map[string]string{ + "calendar_event[set_time_zone]": "1", + "calendar_event[starts_at_time_zone_name]": indianapolis, + "calendar_event[ends_at_time_zone_name]": indianapolis, + "calendar_event[starts_at]": wantDate, + "calendar_event[starts_at_time]": wantClock + ":00", + } { + if got := form.Get(field); got != want { + t.Errorf("%s = %q, want %q", field, got, want) + } + } +} + +// A failed identity read costs the account's zone and nothing else: the form still opens, on +// Local. +func TestAFailedIdentityReadLeavesANewEventOnLocal(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"error":"Something went wrong"}`) + })) + t.Cleanup(server.Close) + + v := dayWithEvents(t) + v.vc.ctx = context.Background() + v.vc.sdk = hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "test-token"}, + hey.WithMaxRetries(0)) + v.calendars = eventFormCalendars() + v.accountZone = indianapolis // what an earlier read served + + v.Update(v.fetchIdentity()()) + v.HandleContentKey(keyPress("a")) + if v.eventForm == nil { + t.Fatal("a did not open the event form") + } + if v.eventForm.starts.zoneName() != "" || v.eventForm.ends.zoneName() != "" { + t.Errorf("zones = %q → %q, want Local", v.eventForm.starts.zoneName(), v.eventForm.ends.zoneName()) + } +} From 37fe7096b0d0c641a81c3a72ea5746c093f55ac7 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 14:46:27 -0400 Subject: [PATCH 03/20] Move HEY's placement of a clock time into the timezone package wallClockOn is how HEY resolves a clock time the clocks skip or repeat, and the TUI's event form needs the same answer to order two moments and to offer a default start. It moves to internal/timezone as WallClock, unchanged. --- internal/cmd/events.go | 10 ++-- internal/cmd/events_occurrence.go | 75 ++-------------------------- internal/cmd/events_zone_test.go | 35 ------------- internal/timezone/wallclock.go | 77 +++++++++++++++++++++++++++++ internal/timezone/wallclock_test.go | 41 +++++++++++++++ 5 files changed, 126 insertions(+), 112 deletions(-) create mode 100644 internal/timezone/wallclock.go create mode 100644 internal/timezone/wallclock_test.go diff --git a/internal/cmd/events.go b/internal/cmd/events.go index 5bb9150f..e645d30e 100644 --- a/internal/cmd/events.go +++ b/internal/cmd/events.go @@ -954,7 +954,7 @@ func endsAfterItStarts(schedule eventSchedule, startZone, endZone clockZone) err // else, which would move it on an edit that never touched it. Two things cannot be sent back // as they are. HEY is sent a clock time in whole minutes, so an end with seconds — an event // HEY imported — would lose them. And of the two moments a clock time names in the hour the -// clocks go back, HEY takes one (see heysChoice), so an end at the other — imported again, +// clocks go back, HEY takes one (see timezone.WallClock), so an end at the other — imported again, // or a zoneless event given a zone — would move by however far the clocks went back. func keepsItsMoment(end string, had time.Time, date, clock string, zone clockZone) error { sent := zone.instant(date, clock) @@ -1012,7 +1012,7 @@ func (z clockZone) instant(date, clock string) time.Time { if z.name == "" { return time.Date(day.Year(), day.Month(), day.Day(), at.Hour(), at.Minute(), 0, 0, time.UTC) } - return wallClockOn(day, at, z.loc) + return timezone.WallClock(day, at, z.loc) } // label is the zone as a refusal names it. @@ -1105,7 +1105,7 @@ func zonelessEnd(had time.Time, date, clock string, loc *time.Location, retyped // moves on to the first one that exists rather than back an hour. day, _ := time.Parse(dateLayout, date) at, _ := time.Parse(clockLayout, clock) - return eventClock(wallClockOn(day, at, loc), time.UTC) + return eventClock(timezone.WallClock(day, at, loc), time.UTC) } // defaultEventStartTime is when an all-day event starts once it is given a time but not one of @@ -1142,9 +1142,9 @@ func defaultEnd(startsOn, startTime, endsOn string, loc *time.Location) (string, } day, _ := time.Parse(dateLayout, startsOn) clock, _ := time.Parse(clockLayout, startTime) - end := wallClockOn(day, clock, loc).Add(eventDuration).In(loc) + end := timezone.WallClock(day, clock, loc).Add(eventDuration).In(loc) endsOn, endTime := end.Format(dateLayout), end.Format(clockLayout) - if placed := wallClockOn(end, end, loc); !placed.Equal(end) { + if placed := timezone.WallClock(end, end, loc); !placed.Equal(end) { return "", "", apierr.ErrUsageHint( fmt.Sprintf("an hour after it starts, the event would end at %s %s %s, a clock time the clocks show twice as they go back, and HEY would place it %s", endsOn, endTime, terminal.SanitizeLine(loc.String()), movedBy(end, placed)), diff --git a/internal/cmd/events_occurrence.go b/internal/cmd/events_occurrence.go index fbf40268..032217d0 100644 --- a/internal/cmd/events_occurrence.go +++ b/internal/cmd/events_occurrence.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" "regexp" - "slices" "strconv" "strings" "time" @@ -17,6 +16,7 @@ import ( "github.com/basecamp/hey-cli/internal/apierr" "github.com/basecamp/hey-cli/internal/terminal" + "github.com/basecamp/hey-cli/internal/timezone" ) // recordingTypeCountdown is how HEY names an event's countdown among a calendar's @@ -462,9 +462,9 @@ func occurrenceInstants(series generated.Recording, day time.Time) (time.Time, t } wall := series.StartsAt.In(loc) named := day.Format(dateLayout) - start := wallClockOn(day, wall, loc) + start := timezone.WallClock(day, wall, loc) for _, delta := range []int{0, -1, 1} { - candidate := wallClockOn(day.AddDate(0, 0, delta), wall, loc) + candidate := timezone.WallClock(day.AddDate(0, 0, delta), wall, loc) if candidate.UTC().Format(dateLayout) == named { start = candidate break @@ -473,75 +473,6 @@ func occurrenceInstants(series generated.Recording, day time.Time) (time.Time, t return start, start.Add(duration) } -// wallClockOn is a clock time on a day, resolved the way HEY resolves one. A clock time that -// does not exist — the hour a zone springs forward over, or the whole of 30 December 2011 in -// Samoa — is moved an hour later and tried again, date and all, until it does: that is what -// ActiveSupport does with a local time TZInfo cannot find. A clock time that happens twice -// is resolved by heysChoice. Go's time.Date is no guide to either: it picks one side of a gap -// or an overlap by its own rules, not HEY's, so a title-only edit would move the day. -func wallClockOn(day, wall time.Time, loc *time.Location) time.Time { - hour, minute, second := wall.Clock() - local := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, second, 0, time.UTC) - for range 48 { - if instants := instantsReading(local, loc); len(instants) > 0 { - return heysChoice(instants) - } - local = local.Add(time.Hour) - } - return time.Date(day.Year(), day.Month(), day.Day(), hour, minute, second, 0, loc) -} - -// localClock is the clock time an instant reads as, held as the same figures in UTC so it -// can be stepped without a zone getting in the way. -func localClock(at time.Time) time.Time { - return time.Date(at.Year(), at.Month(), at.Day(), at.Hour(), at.Minute(), at.Second(), 0, time.UTC) -} - -// instantsReading is every instant whose clock in loc reads local, earliest first: none in a -// gap the clocks skip, two where they go back over the same hour, one anywhere else. Each -// offset the zone has within a day and a half either side is tried in turn. -func instantsReading(local time.Time, loc *time.Location) []time.Time { - var instants []time.Time - seen := map[int]bool{} - for at := local.Add(-36 * time.Hour).In(loc); at.Before(local.Add(36 * time.Hour)); { - _, offset := at.Zone() - if !seen[offset] { - seen[offset] = true - if candidate := local.Add(-time.Duration(offset) * time.Second).In(loc); localClock(candidate).Equal(local) { - instants = append(instants, candidate) - } - } - _, end := at.ZoneBounds() - if end.IsZero() { - break - } - at = end.In(loc) - } - slices.SortFunc(instants, func(a, b time.Time) int { return a.Compare(b) }) - return slices.CompactFunc(instants, time.Time.Equal) -} - -// heysChoice is the instant HEY takes for a clock time that names more than one: -// ActiveSupport asks TZInfo for the period with daylight saving in force, and takes the -// last of those still left — so the daylight-saving side of a fall-back, and the later of -// two when neither side keeps daylight saving, as when Almaty moved its clocks back an hour -// for good in 2024. -func heysChoice(instants []time.Time) time.Time { - if len(instants) == 0 { - return time.Time{} - } - var saving []time.Time - for _, at := range instants { - if at.IsDST() { - saving = append(saving, at) - } - } - if len(saving) > 0 { - instants = saving - } - return instants[len(instants)-1] -} - // occurrenceCountdown is the countdown the write sends: the one --countdown names, or an // existing countdown owned by the recording this write updates. An inherited countdown is // left out of a current-only write so it stays inherited; a future split copies the series' diff --git a/internal/cmd/events_zone_test.go b/internal/cmd/events_zone_test.go index 06cb3c7f..b694a538 100644 --- a/internal/cmd/events_zone_test.go +++ b/internal/cmd/events_zone_test.go @@ -652,41 +652,6 @@ func TestEventsEditOccurrenceOfAZonedSeriesKeepsItsZone(t *testing.T) { } } -// A clock time is placed where HEY places it. These are what ActiveSupport answers for -// Time.zone.parse(clock).change(zone:), the way HEY reads a typed time: a time the clocks -// skip moves an hour on, and a time they repeat is the daylight-saving one of the two, or -// the later where neither keeps daylight saving — Almaty, Volgograd and Moscow going back an -// hour for good. Go's time.Date picks by rules of its own and gets some of each wrong. -func TestWallClockOnPlacesAClockTimeAsHEYDoes(t *testing.T) { - tests := []struct{ name, zone, day, clock, want string }{ - {name: "Zagreb repeats 02:30", zone: "Europe/Zagreb", day: "2026-10-25", clock: "02:30", want: "2026-10-25T00:30:00Z"}, - {name: "New York repeats 01:30", zone: "America/New_York", day: "2026-11-01", clock: "01:30", want: "2026-11-01T05:30:00Z"}, - {name: "New York skips 02:30", zone: "America/New_York", day: "2026-03-08", clock: "02:30", want: "2026-03-08T07:30:00Z"}, - {name: "Lord Howe repeats 01:45", zone: "Australia/Lord_Howe", day: "2026-04-05", clock: "01:45", want: "2026-04-04T14:45:00Z"}, - {name: "Lord Howe skips 02:15", zone: "Australia/Lord_Howe", day: "2026-10-04", clock: "02:15", want: "2026-10-03T16:15:00Z"}, - {name: "Samoa skips 30 December 2011, noon", zone: "Pacific/Apia", day: "2011-12-30", clock: "12:00", want: "2011-12-30T10:00:00Z"}, - {name: "Samoa skips 30 December 2011, midnight", zone: "Pacific/Apia", day: "2011-12-30", clock: "00:00", want: "2011-12-30T10:00:00Z"}, - {name: "Samoa skips 30 December 2011, 23:59", zone: "Pacific/Apia", day: "2011-12-30", clock: "23:59", want: "2011-12-30T10:59:00Z"}, - {name: "an ordinary day", zone: "America/New_York", day: "2026-06-10", clock: "10:00", want: "2026-06-10T14:00:00Z"}, - {name: "Almaty repeats 23:30 for good", zone: "Asia/Almaty", day: "2024-02-29", clock: "23:30", want: "2024-02-29T18:30:00Z"}, - {name: "Volgograd repeats 01:30 for good", zone: "Europe/Volgograd", day: "2020-12-27", clock: "01:30", want: "2020-12-26T22:30:00Z"}, - {name: "Moscow repeats 01:30 for good", zone: "Europe/Moscow", day: "2014-10-26", clock: "01:30", want: "2014-10-25T22:30:00Z"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - loc, err := time.LoadLocation(tt.zone) - if err != nil { - t.Fatal(err) - } - day, _ := time.Parse(dateLayout, tt.day) - clock, _ := time.Parse(clockLayout, tt.clock) - if got := wallClockOn(day, clock, loc).UTC().Format(time.RFC3339); got != tt.want { - t.Errorf("wallClockOn(%s %s) = %s, want %s", tt.day, tt.clock, got, tt.want) - } - }) - } -} - // A time typed on a zoneless event in the hour the clocks repeat is the first of the two, // as HEY would read it: 02:30 on the night Zagreb falls back is 00:30 UTC, not 01:30. func TestEventsEditZonelessEventPlacesARepeatedTimeAsHEYDoes(t *testing.T) { diff --git a/internal/timezone/wallclock.go b/internal/timezone/wallclock.go new file mode 100644 index 00000000..9896ed20 --- /dev/null +++ b/internal/timezone/wallclock.go @@ -0,0 +1,77 @@ +package timezone + +import ( + "slices" + "time" +) + +// WallClock is a clock time on a day, resolved the way HEY resolves one. A clock time that +// does not exist — the hour a zone springs forward over, or the whole of 30 December 2011 in +// Samoa — is moved an hour later and tried again, date and all, until it does: that is what +// ActiveSupport does with a local time TZInfo cannot find. A clock time that happens twice +// is resolved by heysChoice. Go's time.Date is no guide to either: it picks one side of a gap +// or an overlap by its own rules, not HEY's, so a title-only edit would move the day. +// +// Only day's date and wall's clock are read; their zones are not. +func WallClock(day, wall time.Time, loc *time.Location) time.Time { + hour, minute, second := wall.Clock() + local := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, second, 0, time.UTC) + for range 48 { + if instants := instantsReading(local, loc); len(instants) > 0 { + return heysChoice(instants) + } + local = local.Add(time.Hour) + } + return time.Date(day.Year(), day.Month(), day.Day(), hour, minute, second, 0, loc) +} + +// localClock is the clock time an instant reads as, held as the same figures in UTC so it +// can be stepped without a zone getting in the way. +func localClock(at time.Time) time.Time { + return time.Date(at.Year(), at.Month(), at.Day(), at.Hour(), at.Minute(), at.Second(), 0, time.UTC) +} + +// instantsReading is every instant whose clock in loc reads local, earliest first: none in a +// gap the clocks skip, two where they go back over the same hour, one anywhere else. Each +// offset the zone has within a day and a half either side is tried in turn. +func instantsReading(local time.Time, loc *time.Location) []time.Time { + var instants []time.Time + seen := map[int]bool{} + for at := local.Add(-36 * time.Hour).In(loc); at.Before(local.Add(36 * time.Hour)); { + _, offset := at.Zone() + if !seen[offset] { + seen[offset] = true + if candidate := local.Add(-time.Duration(offset) * time.Second).In(loc); localClock(candidate).Equal(local) { + instants = append(instants, candidate) + } + } + _, end := at.ZoneBounds() + if end.IsZero() { + break + } + at = end.In(loc) + } + slices.SortFunc(instants, func(a, b time.Time) int { return a.Compare(b) }) + return slices.CompactFunc(instants, time.Time.Equal) +} + +// heysChoice is the instant HEY takes for a clock time that names more than one: +// ActiveSupport asks TZInfo for the period with daylight saving in force, and takes the +// last of those still left — so the daylight-saving side of a fall-back, and the later of +// two when neither side keeps daylight saving, as when Almaty moved its clocks back an hour +// for good in 2024. +func heysChoice(instants []time.Time) time.Time { + if len(instants) == 0 { + return time.Time{} + } + var saving []time.Time + for _, at := range instants { + if at.IsDST() { + saving = append(saving, at) + } + } + if len(saving) > 0 { + instants = saving + } + return instants[len(instants)-1] +} diff --git a/internal/timezone/wallclock_test.go b/internal/timezone/wallclock_test.go new file mode 100644 index 00000000..b84d479e --- /dev/null +++ b/internal/timezone/wallclock_test.go @@ -0,0 +1,41 @@ +package timezone + +import ( + "testing" + "time" +) + +// A clock time is placed where HEY places it. These are what ActiveSupport answers for +// Time.zone.parse(clock).change(zone:), the way HEY reads a typed time: a time the clocks +// skip moves an hour on, and a time they repeat is the daylight-saving one of the two, or +// the later where neither keeps daylight saving — Almaty, Volgograd and Moscow going back an +// hour for good. Go's time.Date picks by rules of its own and gets some of each wrong. +func TestWallClockPlacesAClockTimeAsHEYDoes(t *testing.T) { + tests := []struct{ name, zone, day, clock, want string }{ + {name: "Zagreb repeats 02:30", zone: "Europe/Zagreb", day: "2026-10-25", clock: "02:30", want: "2026-10-25T00:30:00Z"}, + {name: "New York repeats 01:30", zone: "America/New_York", day: "2026-11-01", clock: "01:30", want: "2026-11-01T05:30:00Z"}, + {name: "New York skips 02:30", zone: "America/New_York", day: "2026-03-08", clock: "02:30", want: "2026-03-08T07:30:00Z"}, + {name: "Lord Howe repeats 01:45", zone: "Australia/Lord_Howe", day: "2026-04-05", clock: "01:45", want: "2026-04-04T14:45:00Z"}, + {name: "Lord Howe skips 02:15", zone: "Australia/Lord_Howe", day: "2026-10-04", clock: "02:15", want: "2026-10-03T16:15:00Z"}, + {name: "Samoa skips 30 December 2011, noon", zone: "Pacific/Apia", day: "2011-12-30", clock: "12:00", want: "2011-12-30T10:00:00Z"}, + {name: "Samoa skips 30 December 2011, midnight", zone: "Pacific/Apia", day: "2011-12-30", clock: "00:00", want: "2011-12-30T10:00:00Z"}, + {name: "Samoa skips 30 December 2011, 23:59", zone: "Pacific/Apia", day: "2011-12-30", clock: "23:59", want: "2011-12-30T10:59:00Z"}, + {name: "an ordinary day", zone: "America/New_York", day: "2026-06-10", clock: "10:00", want: "2026-06-10T14:00:00Z"}, + {name: "Almaty repeats 23:30 for good", zone: "Asia/Almaty", day: "2024-02-29", clock: "23:30", want: "2024-02-29T18:30:00Z"}, + {name: "Volgograd repeats 01:30 for good", zone: "Europe/Volgograd", day: "2020-12-27", clock: "01:30", want: "2020-12-26T22:30:00Z"}, + {name: "Moscow repeats 01:30 for good", zone: "Europe/Moscow", day: "2014-10-26", clock: "01:30", want: "2014-10-25T22:30:00Z"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + loc, err := time.LoadLocation(tt.zone) + if err != nil { + t.Fatal(err) + } + day, _ := time.Parse("2006-01-02", tt.day) + clock, _ := time.Parse("15:04", tt.clock) + if got := WallClock(day, clock, loc).UTC().Format(time.RFC3339); got != tt.want { + t.Errorf("WallClock(%s %s) = %s, want %s", tt.day, tt.clock, got, tt.want) + } + }) + } +} From d7a5b85940fd908b41ed8491937a57f63e9c6010 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 14:46:27 -0400 Subject: [PATCH 04/20] Place the event form's times as HEY will, and wait for the account's zone The form ordered two moments with time.ParseInLocation, which puts 02:30 on the morning New York springs forward at 01:30, where HEY puts it at 03:30: 02:30 to 03:00 passed the form and was refused by HEY. A moment in a named zone is now placed with timezone.WallClock. The default start went the same way, and at 01:41 that morning offered an 02:00 Go put back at 01:00; it is now 03:00. The account's zone came from whatever the last identity read said until the current one answered, so a form opened in that window used an earlier visit's zone, or kept it after a failed read. The zone is now cleared when the Calendar starts its read, and a new-event form opened before the answer takes the zone when it lands, as long as the reader has not changed the day, a time or a zone. --- docs/tui.md | 3 +- internal/tui/calendar.go | 10 +++- internal/tui/datetime.go | 42 ++++++++++----- internal/tui/event_form.go | 46 ++++++++++++++++- internal/tui/event_form_zone_test.go | 76 +++++++++++++++++++++++++++- 5 files changed, 159 insertions(+), 18 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index 5e2e8511..ebcc7ad2 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -149,7 +149,8 @@ Press Shift+C to open Calendar, then `c` to manage time track categories. Create 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, -the form opens on `Local` instead. The zone list still offers `Local` (your machine's clock, +the form opens on `Local` instead, and a form opened before the read answers takes the zone +when it does, unless you have already changed its times. The zone list still offers `Local` (your machine's clock, saved without a zone) and every other zone. An edit keeps the event's own zone, and an event saved without one stays on `Local`; an all-day event given a time takes the account's zone. diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index 79a91e72..31870335 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -397,7 +397,8 @@ type calendarView struct { // accountZone is the zone HEY's web app reads a typed time in, as the identity served it // when this section was last entered, and what a new event's times are written in. It is // read with the rest of the identity rather than kept anywhere longer-lived, so a zone - // changed on the web is picked up the next time the calendar is opened. + // changed on the web is picked up the next time the calendar is opened, and it is empty + // until that read answers. accountZone string // now is the clock the calendar anchors on. It is read on every fetch and @@ -528,6 +529,10 @@ func newCalendarView(vc *viewContext) *calendarView { } func (v *calendarView) Init() tea.Cmd { + // The account's zone is this read's answer rather than the last one's, so a zone changed + // on the web, or a read that fails, is never covered by what an earlier visit was told. A + // form opened before the answer lands opens on Local and takes the zone when it does. + v.accountZone = "" cmds := []tea.Cmd{v.fetchIdentity(), v.requestOngoingTrack(), v.followClock()} if len(v.calendars) == 0 { cmds = append(cmds, v.requestCalendars()) @@ -562,6 +567,9 @@ func (v *calendarView) Update(msg tea.Msg) (tea.Cmd, bool) { v.firstWeekDay = msg.firstWeekDay v.use24Hour = msg.use24Hour v.accountZone = msg.timeZone + if v.eventForm != nil { + v.eventForm.adoptAccountZone(msg.timeZone) + } v.rebuildView() return nil, true diff --git a/internal/tui/datetime.go b/internal/tui/datetime.go index aa017700..eaa2d928 100644 --- a/internal/tui/datetime.go +++ b/internal/tui/datetime.go @@ -13,6 +13,8 @@ import ( "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + + "github.com/basecamp/hey-cli/internal/timezone" ) // A moment on a form is three questions that only make sense together — which day, what @@ -69,11 +71,16 @@ func newDateTimePicker(at time.Time, allDay bool) *dateTimePicker { zone: localZoneLabel, choices: zoneChoices(), } - picker.dateInput.SetValue(at.Format("2006-01-02")) - picker.timeInput.SetValue(at.Format("15:04")) + picker.setMoment(at) return picker } +// setMoment puts a date and a time of day on the widget, read on at's own clock. +func (p *dateTimePicker) setMoment(at time.Time) { + p.dateInput.SetValue(at.Format("2006-01-02")) + p.timeInput.SetValue(at.Format("15:04")) +} + func dateTimeInput(placeholder string, width int) textinput.Model { input := newTextInput() input.Prompt = "" @@ -128,21 +135,30 @@ func (p *dateTimePicker) zoneName() string { // moment is the date and time read as belonging to the zone chosen, which is what a form // comparing two of these wants. +// +// A named zone is read the way HEY will read it, since HEY is sent the clock and places it +// itself: a time the clocks skip moves on to one that exists, and a time they repeat is the +// one HEY takes. Local is read the way the form converts it, which is what HEY is sent. func (p *dateTimePicker) moment() (time.Time, bool) { - in := time.Local - if name := p.zoneName(); name != "" { - zone, ok := loadEventZone(name) - if !ok { - return time.Time{}, false - } - in = zone - } if p.allDay { - at, err := time.ParseInLocation("2006-01-02", p.date(), in) + at, err := time.ParseInLocation("2006-01-02", p.date(), time.Local) + return at, err == nil + } + name := p.zoneName() + if name == "" { + at, err := time.ParseInLocation("2006-01-02 15:04", p.date()+" "+p.clock(), time.Local) return at, err == nil } - at, err := time.ParseInLocation("2006-01-02 15:04", p.date()+" "+p.clock(), in) - return at, err == nil + zone, ok := loadEventZone(name) + if !ok { + return time.Time{}, false + } + day, dayErr := time.Parse("2006-01-02", p.date()) + clock, clockErr := time.Parse("15:04", p.clock()) + if dayErr != nil || clockErr != nil { + return time.Time{}, false + } + return timezone.WallClock(day, clock, zone), true } // problem says the first thing wrong with what the reader filled in, and nothing when the diff --git a/internal/tui/event_form.go b/internal/tui/event_form.go index 9bb58b03..7e056aac 100644 --- a/internal/tui/event_form.go +++ b/internal/tui/event_form.go @@ -96,6 +96,12 @@ type eventForm struct { starts *dateTimePicker ends *dateTimePicker + // on is the day the form was opened on, and offered is when it offered the event, as + // schedule() reads it: together they are what lets a new event take the account's zone + // when the identity read naming it lands after the form opened — see adoptAccountZone. + on time.Time + offered string + // chosenReminders runs alongside eventReminders, and notify is the one the arrows are on. chosenReminders []bool notify int @@ -203,9 +209,41 @@ func newEventForm(mode eventFormMode, event Recording, on time.Time, calendars [ form.ends = newDateTimePicker(inZoneNamed(ends, event.EndsAtZone), form.allDay) form.starts.setZoneName(startZone) form.ends.setZoneName(endZone) + form.on = on + form.offered = form.schedule() return form } +// adoptAccountZone gives a new event the account's zone when the identity read that names it +// lands after the form opened on Local, offering the next whole hour on that clock instead. +// It does so only while the form still offers what it opened with: a reader who has touched +// the day, a time or a zone has answered the question already, and moving the times out from +// under them would be worse than Local. A zone that cannot be used changes nothing. +func (f *eventForm) adoptAccountZone(name string) { + if f.mode != eventFormCreate || f.schedule() != f.offered { + return + } + account, zone := usableZone(name) + if zone == nil { + return + } + starts := newEventStart(f.on, zone) + f.starts.setMoment(starts) + f.starts.setZoneName(account) + f.ends.setMoment(starts.Add(time.Hour).In(zone)) + f.ends.setZoneName(account) + f.offered = f.schedule() +} + +// schedule is everything the form says about when the event is, as one string to compare. +func (f *eventForm) schedule() string { + return strings.Join([]string{ + strconv.FormatBool(f.allDay), + f.starts.date(), f.starts.timeInput.Value(), f.starts.zone, + f.ends.date(), f.ends.timeInput.Value(), f.ends.zone, + }, "\x00") +} + // usableZone is the account's zone when HEY can look it up by that name, and nothing when it // cannot: an account with no zone set, a read that failed and a name this build does not know // all open the form on Local instead. @@ -353,13 +391,19 @@ func zoneMatchesLocal(name string) bool { // looking at, at the next whole hour on the clock the event is written on, so a form opened at // 09:41 offers 10:00 rather than 09:41. The day is the one on screen even where that clock is // already on another — the reader chose the day by looking at it. +// +// The hour is placed as HEY will place it, so an hour the clocks skip opens on the first one +// after it: 01:41 on the morning New York springs forward offers 03:00, not an 02:00 that Go +// would put back at 01:00, before the form was opened. func newEventStart(on time.Time, zone *time.Location) time.Time { clock := on.In(zone) hour := clock.Hour() if clock.Minute() != 0 || clock.Second() != 0 || clock.Nanosecond() != 0 { hour++ } - return time.Date(on.Year(), on.Month(), on.Day(), hour, 0, 0, 0, zone) + // Written in UTC first so that hour 24 rolls over to the next day and nothing else moves. + wall := time.Date(on.Year(), on.Month(), on.Day(), hour, 0, 0, 0, time.UTC) + return timezone.WallClock(wall, wall, zone).In(zone) } // indexOfCalendar finds the calendar an event is filed on. The id is the answer where the diff --git a/internal/tui/event_form_zone_test.go b/internal/tui/event_form_zone_test.go index 7d1ed02b..778c6f74 100644 --- a/internal/tui/event_form_zone_test.go +++ b/internal/tui/event_form_zone_test.go @@ -284,14 +284,86 @@ func TestAFailedIdentityReadLeavesANewEventOnLocal(t *testing.T) { v.vc.sdk = hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "test-token"}, hey.WithMaxRetries(0)) v.calendars = eventFormCalendars() - v.accountZone = indianapolis // what an earlier read served + v.accountZone = indianapolis // what an earlier visit was told - v.Update(v.fetchIdentity()()) + // Entering the calendar starts a fresh read, and what an earlier visit was told does not + // stand in for it while it is on its way, nor after it fails. + v.Init() v.HandleContentKey(keyPress("a")) if v.eventForm == nil { t.Fatal("a did not open the event form") } + v.Update(v.fetchIdentity()()) if v.eventForm.starts.zoneName() != "" || v.eventForm.ends.zoneName() != "" { t.Errorf("zones = %q → %q, want Local", v.eventForm.starts.zoneName(), v.eventForm.ends.zoneName()) } } + +// A new event opened before the identity read lands takes the account's zone when it does, on +// the next whole hour of that clock — unless the reader has already touched when it is. +func TestANewEventTakesTheAccountZoneWhenTheIdentityLandsLate(t *testing.T) { + v, _ := calendarWithEventServer(t) + v.Init() + identity := v.fetchIdentity()() + + v.HandleContentKey(keyPress("a")) + if v.eventForm == nil || v.eventForm.starts.zoneName() != "" { + t.Fatal("a form opened before the read did not open on Local") + } + v.eventForm.title.SetValue("Design review") + v.Update(identity) + + want := newEventStart(v.day(), mustZone(t, indianapolis)) + form := v.eventForm + if form.starts.zoneName() != indianapolis || form.ends.zoneName() != indianapolis { + t.Errorf("zones = %q → %q, want the account's", form.starts.zoneName(), form.ends.zoneName()) + } + if form.starts.date() != want.Format("2006-01-02") || form.starts.clock() != want.Format("15:04") { + t.Errorf("start = %s %s, want %s", form.starts.date(), form.starts.clock(), want) + } + if got := form.title.Value(); got != "Design review" { + t.Errorf("title = %q, want what the reader typed kept", got) + } + + // A reader who has changed a time has answered the question; the read does not move it. + v.eventForm = nil + v.Init() + v.HandleContentKey(keyPress("a")) + v.eventForm.starts.timeInput.SetValue("15:00") + v.Update(identity) + if v.eventForm.starts.zoneName() != "" || v.eventForm.starts.clock() != "15:00" { + t.Errorf("start = %s %q, want 15:00 left on Local", v.eventForm.starts.clock(), v.eventForm.starts.zoneName()) + } +} + +// An hour the clocks skip is not offered: 01:41 on the morning New York springs forward opens +// on 03:00, the next whole hour that exists, where Go would put 02:00 back at 01:00. +func TestNewEventFormSkipsTheHourTheClocksSkip(t *testing.T) { + newYork := mustZone(t, "America/New_York") + on := time.Date(2026, 3, 8, 1, 41, 0, 0, newYork) + form := newAccountZoneForm(eventFormCreate, Recording{}, on, "America/New_York") + + values := form.values() + if values.StartsAt != "2026-03-08" || values.StartTime != "03:00" || values.EndTime != "04:00" { + t.Errorf("times = %s %s → %s, want 03:00 → 04:00", values.StartsAt, values.StartTime, values.EndTime) + } +} + +// Times are ordered where HEY will place them. HEY moves 02:30 on the morning New York springs +// forward on to 03:30, so an end at 03:00 is before it — Go alone would read 02:30 as 01:30 and +// let it through, and HEY would refuse the write. +func TestEventFormOrdersASkippedTimeAsHEYPlacesIt(t *testing.T) { + on := time.Date(2026, 3, 8, 1, 0, 0, 0, mustZone(t, "America/New_York")) + form := newAccountZoneForm(eventFormCreate, Recording{}, on, "America/New_York") + form.title.SetValue("Night shift handover") + form.starts.timeInput.SetValue("02:30") + form.ends.timeInput.SetValue("03:00") + + if got := form.validate(); got != "The end is before the start" { + t.Errorf("validate = %q, want the end refused as before the start", got) + } + form.ends.timeInput.SetValue("03:30") + if got := form.validate(); got != "" { + t.Errorf("validate = %q, want an end at the start HEY places taken", got) + } +} From 4f2d34089192a640ebeadc9758882ad15709e605 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 14:47:02 -0400 Subject: [PATCH 05/20] Say the zone list is the machine's zones or a shortlist The zone picker lists the zones in the machine's zone database, and a shortlist where that database cannot be listed, so docs/tui.md no longer says it offers every zone. --- docs/tui.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index ebcc7ad2..c1a56dcd 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -150,8 +150,10 @@ A new event's times are written in your HEY account's time zone, the one HEY's w `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, the form opens on `Local` instead, and a form opened before the read answers takes the zone -when it does, unless you have already changed its times. The zone list still offers `Local` (your machine's clock, -saved without a zone) and every other zone. An edit keeps the event's own zone, and an event -saved without one stays on `Local`; an all-day event given a time takes the account's zone. +when it does, unless you have already changed its times. The zone list still offers `Local` +(your machine's clock, saved without a zone) and the zones in your machine's zone database, +or a shortlist of common zones where that database cannot be listed. An edit keeps the +event's own zone, and an event saved without one stays on `Local`; an all-day event given a +time takes the account's zone. In Calendar, press `a` to create a habit. Habits visible in the current calendar range can be selected with `[` and `]`, edited with `e`, and deleted by pressing `x` twice. Habit forms use Tab to move between fields and Ctrl+S to save. From f5250e2e77944972fe226ab7c176e2c2e53b4e05 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 14:54:20 -0400 Subject: [PATCH 06/20] Take only the latest identity read, and end a new event after it starts Each visit to the Calendar starts an identity read, and an earlier visit's answer could land after a later one's and put its zone back. The answer now carries which visit's read it is, and anything but the latest is dropped. An hour on from the first 01:00 of the night New York falls back is the second 01:00, which the form showed and sent as the same clock as the start, and HEY places both at the first: a zero-length event. A new event's end now moves on an hour at a time until it reads as after the start, which there is 02:00. --- internal/tui/calendar.go | 18 +++++++++-- internal/tui/event_form.go | 26 +++++++++++++++- internal/tui/event_form_zone_test.go | 45 +++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index 31870335..42e3eab8 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -239,7 +239,12 @@ type yearLoadedMsg struct { // identityLoadedMsg stays off the request lane: the identity's calendar preferences are // read once, alongside the calendars rather than instead of them, so putting it on the // lane would cancel the read it was batched with. +// +// It carries a count of its own instead: read is which visit's read this answers, so an +// answer from an earlier visit that lands after a later one's is dropped rather than +// putting that visit's zone back. type identityLoadedMsg struct { + read int firstWeekDay time.Weekday use24Hour bool // timeZone is the account's zone as the identity serves it, and empty when it has none @@ -400,6 +405,9 @@ type calendarView struct { // changed on the web is picked up the next time the calendar is opened, and it is empty // until that read answers. accountZone string + // identityRead counts the identity reads started, one a visit, so only the latest is + // taken; see identityLoadedMsg. + identityRead int // now is the clock the calendar anchors on. It is read on every fetch and // every render, so a TUI left open overnight moves to the new day instead of @@ -533,6 +541,7 @@ func (v *calendarView) Init() tea.Cmd { // on the web, or a read that fails, is never covered by what an earlier visit was told. A // form opened before the answer lands opens on Local and takes the zone when it does. v.accountZone = "" + v.identityRead++ cmds := []tea.Cmd{v.fetchIdentity(), v.requestOngoingTrack(), v.followClock()} if len(v.calendars) == 0 { cmds = append(cmds, v.requestCalendars()) @@ -564,6 +573,9 @@ func (v *calendarView) Update(msg tea.Msg) (tea.Cmd, bool) { return v.animate(), true case identityLoadedMsg: + if msg.read != v.identityRead { + return nil, true + } v.firstWeekDay = msg.firstWeekDay v.use24Hour = msg.use24Hour v.accountZone = msg.timeZone @@ -2426,19 +2438,21 @@ func sdkRecordingToModel(r generated.Recording) Recording { // --- Fetch commands --- func (v *calendarView) fetchIdentity() tea.Cmd { + read := v.identityRead return func() tea.Msg { if v.vc.sdk == nil || v.vc.ctx == nil { - return identityLoadedMsg{firstWeekDay: time.Monday} + return identityLoadedMsg{read: read, firstWeekDay: time.Monday} } identity, err := v.vc.sdk.Identity().GetIdentity(v.vc.ctx) if err != nil || identity == nil { - return identityLoadedMsg{firstWeekDay: time.Monday} + return identityLoadedMsg{read: read, firstWeekDay: time.Monday} } wd := identity.FirstWeekDay if wd < 0 || wd > 6 { wd = 1 // default to Monday } return identityLoadedMsg{ + read: read, firstWeekDay: time.Weekday(wd), use24Hour: identity.TimeFormat == string(hey.TimeFormatTwentyFourHour), timeZone: identity.TimeZone, diff --git a/internal/tui/event_form.go b/internal/tui/event_form.go index 7e056aac..85e07dca 100644 --- a/internal/tui/event_form.go +++ b/internal/tui/event_form.go @@ -209,11 +209,35 @@ func newEventForm(mode eventFormMode, event Recording, on time.Time, calendars [ form.ends = newDateTimePicker(inZoneNamed(ends, event.EndsAtZone), form.allDay) form.starts.setZoneName(startZone) form.ends.setZoneName(endZone) + if mode == eventFormCreate { + form.offerAnHour(starts) + } form.on = on form.offered = form.schedule() return form } +// offerAnHour ends a new event an hour after it starts, as the end reads once it is sent. An +// hour on from the first 01:00 of the night New York falls back is the second 01:00, which +// reads — and is sent — as the same clock as the start, and HEY places both at the first: a +// zero-length event. The end moves on an hour at a time until it reads as after the start, +// which there is 02:00, an hour on the clock. +func (f *eventForm) offerAnHour(starts time.Time) { + ends := starts.Add(time.Hour) + f.ends.setMoment(ends) + begins, ok := f.starts.moment() + if !ok { + return + } + for range 3 { + if finishes, ok := f.ends.moment(); !ok || finishes.After(begins) { + return + } + ends = ends.Add(time.Hour) + f.ends.setMoment(ends) + } +} + // adoptAccountZone gives a new event the account's zone when the identity read that names it // lands after the form opened on Local, offering the next whole hour on that clock instead. // It does so only while the form still offers what it opened with: a reader who has touched @@ -230,8 +254,8 @@ func (f *eventForm) adoptAccountZone(name string) { starts := newEventStart(f.on, zone) f.starts.setMoment(starts) f.starts.setZoneName(account) - f.ends.setMoment(starts.Add(time.Hour).In(zone)) f.ends.setZoneName(account) + f.offerAnHour(starts) f.offered = f.schedule() } diff --git a/internal/tui/event_form_zone_test.go b/internal/tui/event_form_zone_test.go index 778c6f74..8e982443 100644 --- a/internal/tui/event_form_zone_test.go +++ b/internal/tui/event_form_zone_test.go @@ -330,12 +330,55 @@ func TestANewEventTakesTheAccountZoneWhenTheIdentityLandsLate(t *testing.T) { v.Init() v.HandleContentKey(keyPress("a")) v.eventForm.starts.timeInput.SetValue("15:00") - v.Update(identity) + v.Update(v.fetchIdentity()()) if v.eventForm.starts.zoneName() != "" || v.eventForm.starts.clock() != "15:00" { t.Errorf("start = %s %q, want 15:00 left on Local", v.eventForm.starts.clock(), v.eventForm.starts.zoneName()) } } +// Each visit to the calendar reads the identity again, and only the latest read is taken: an +// earlier visit's answer landing late does not put its zone back, and a form opened on Local +// does not take it. +func TestAnEarlierVisitsIdentityReadIsDropped(t *testing.T) { + v, _ := calendarWithEventServer(t) + v.Init() + earlier := v.fetchIdentity()() + v.Init() + + v.HandleContentKey(keyPress("a")) + v.Update(earlier) + if v.accountZone != "" { + t.Errorf("accountZone = %q, want the earlier visit's answer dropped", v.accountZone) + } + if v.eventForm.starts.zoneName() != "" { + t.Errorf("zone = %q, want the form left on Local", v.eventForm.starts.zoneName()) + } + + v.Update(v.fetchIdentity()()) + if v.accountZone != indianapolis || v.eventForm.starts.zoneName() != indianapolis { + t.Errorf("zone = %q / %q, want the latest read's", v.accountZone, v.eventForm.starts.zoneName()) + } +} + +// An hour on from the first 01:00 of the night New York falls back is the second 01:00, which +// is sent as the same clock and placed by HEY at the first: a zero-length event. The form +// offers 01:00 to 02:00 instead. +func TestNewEventFormEndsAfterItStartsAcrossAFallBack(t *testing.T) { + newYork := mustZone(t, "America/New_York") + on := time.Date(2026, 11, 1, 0, 41, 0, 0, newYork) + form := newAccountZoneForm(eventFormCreate, Recording{}, on, "America/New_York") + + values := form.values() + if values.StartsAt != "2026-11-01" || values.StartTime != "01:00" || values.EndTime != "02:00" { + t.Errorf("times = %s %s → %s, want 01:00 → 02:00", values.StartsAt, values.StartTime, values.EndTime) + } + starts, _ := form.starts.moment() + ends, _ := form.ends.moment() + if !ends.After(starts) { + t.Errorf("the end %s is not after the start %s", ends, starts) + } +} + // An hour the clocks skip is not offered: 01:41 on the morning New York springs forward opens // on 03:00, the next whole hour that exists, where Go would put 02:00 back at 01:00. func TestNewEventFormSkipsTheHourTheClocksSkip(t *testing.T) { From 124b2fdd866b7042b86b94c3e9844c807d5396ec Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 15:07:50 -0400 Subject: [PATCH 07/20] Say where HEY places a sent clock time, and whether an instant survives it timezone.Placed is the instant HEY stores for a date and clock sent with a zone, or read as UTC without one; timezone.Representable is whether an instant comes back as itself when sent as a clock time in a zone. The CLI's clockZone.instant is now Placed, and its movedBy moves across as timezone.MovedBy, so the TUI's event form can refuse the same moves the CLI refuses and say them the same way. --- internal/cmd/events.go | 37 ++++------------- internal/timezone/wallclock.go | 48 +++++++++++++++++++++++ internal/timezone/wallclock_test.go | 61 +++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 30 deletions(-) diff --git a/internal/cmd/events.go b/internal/cmd/events.go index e645d30e..fdb401e4 100644 --- a/internal/cmd/events.go +++ b/internal/cmd/events.go @@ -965,38 +965,15 @@ func keepsItsMoment(end string, had time.Time, date, clock string, zone clockZon if !had.Equal(had.Truncate(time.Minute)) { return apierr.ErrUsageHint( fmt.Sprintf("the event's %s is at %s %s, and HEY is only sent whole minutes, so the edit would move it %s", - end, had.In(zone.loc).Format(time.DateTime+".999999999"), zone.label(), movedBy(had, sent)), + end, had.In(zone.loc).Format(time.DateTime+".999999999"), zone.label(), timezone.MovedBy(had, sent)), hint) } return apierr.ErrUsageHint( fmt.Sprintf("the event's %s, %s %s %s, falls in the hour the clocks repeat as they go back, and HEY would place that clock time at its other moment, so the edit would move it %s", - end, date, clock, zone.label(), movedBy(had, sent)), + end, date, clock, zone.label(), timezone.MovedBy(had, sent)), hint+", or choose a time outside that hour") } -// movedBy says how far and which way an end would move, from had to sent. -func movedBy(had, sent time.Time) string { - moved, way := sent.Sub(had), "later" - if moved < 0 { - moved, way = -moved, "earlier" - } - switch { - case moved == time.Hour: - return "an hour " + way - case moved%time.Hour == 0: - return fmt.Sprintf("%d hours %s", moved/time.Hour, way) - case moved%time.Minute == 0: - return fmt.Sprintf("%d minutes %s", moved/time.Minute, way) - case moved < time.Millisecond: - return "less than a millisecond " + way - case moved < time.Second: - return fmt.Sprintf("%s milliseconds %s", strconv.FormatFloat(float64(moved)/float64(time.Millisecond), 'f', -1, 64), way) - case moved < time.Minute: - return fmt.Sprintf("%s seconds %s", strconv.FormatFloat(moved.Seconds(), 'f', -1, 64), way) - } - return moved.String() + " " + way -} - // clockZone is the zone one end of an edited event is read and written in. An end with no // name is zoneless: its clock times are read in loc and go back to HEY as UTC, zone and all. type clockZone struct { @@ -1007,12 +984,12 @@ type clockZone struct { // instant is when a date and clock time sent in this zone happen: placed as HEY places them // for a named zone, and as UTC for a zoneless end, which is sent that way. func (z clockZone) instant(date, clock string) time.Time { - day, _ := time.Parse(dateLayout, date) - at, _ := time.Parse(clockLayout, clock) + loc := z.loc if z.name == "" { - return time.Date(day.Year(), day.Month(), day.Day(), at.Hour(), at.Minute(), 0, 0, time.UTC) + loc = nil } - return timezone.WallClock(day, at, z.loc) + at, _ := timezone.Placed(date, clock, loc) + return at } // label is the zone as a refusal names it. @@ -1147,7 +1124,7 @@ func defaultEnd(startsOn, startTime, endsOn string, loc *time.Location) (string, if placed := timezone.WallClock(end, end, loc); !placed.Equal(end) { return "", "", apierr.ErrUsageHint( fmt.Sprintf("an hour after it starts, the event would end at %s %s %s, a clock time the clocks show twice as they go back, and HEY would place it %s", - endsOn, endTime, terminal.SanitizeLine(loc.String()), movedBy(end, placed)), + endsOn, endTime, terminal.SanitizeLine(loc.String()), timezone.MovedBy(end, placed)), "pass --end-time to say when it ends") } return endsOn, endTime, nil diff --git a/internal/timezone/wallclock.go b/internal/timezone/wallclock.go index 9896ed20..c40ac721 100644 --- a/internal/timezone/wallclock.go +++ b/internal/timezone/wallclock.go @@ -1,7 +1,9 @@ package timezone import ( + "fmt" "slices" + "strconv" "time" ) @@ -25,6 +27,52 @@ func WallClock(day, wall time.Time, loc *time.Location) time.Time { return time.Date(day.Year(), day.Month(), day.Day(), hour, minute, second, 0, loc) } +// Placed is the instant HEY stores for a date and a clock time sent with a zone: placed in +// that zone by WallClock, or read as UTC when no zone is sent, since HEY answers a JSON +// request in UTC. It answers false for a date or a time that does not parse. +func Placed(date, clock string, loc *time.Location) (time.Time, bool) { + day, dayErr := time.Parse("2006-01-02", date) + at, clockErr := time.Parse("15:04", clock) + if dayErr != nil || clockErr != nil { + return time.Time{}, false + } + if loc == nil { + return time.Date(day.Year(), day.Month(), day.Day(), at.Hour(), at.Minute(), 0, 0, time.UTC), true + } + return WallClock(day, at, loc), true +} + +// Representable is whether an instant can be sent to HEY as a clock time in loc and come +// back as itself: false for the second of two moments a clock time names as the clocks go +// back, when HEY takes the first, and for an instant between whole minutes. +func Representable(at time.Time, loc *time.Location) bool { + on := at.In(loc) + return at.Equal(at.Truncate(time.Minute)) && WallClock(on, on, loc).Equal(at) +} + +// MovedBy says how far and which way an instant would move, from had to sent. +func MovedBy(had, sent time.Time) string { + moved, way := sent.Sub(had), "later" + if moved < 0 { + moved, way = -moved, "earlier" + } + switch { + case moved == time.Hour: + return "an hour " + way + case moved%time.Hour == 0: + return fmt.Sprintf("%d hours %s", moved/time.Hour, way) + case moved%time.Minute == 0: + return fmt.Sprintf("%d minutes %s", moved/time.Minute, way) + case moved < time.Millisecond: + return "less than a millisecond " + way + case moved < time.Second: + return fmt.Sprintf("%s milliseconds %s", strconv.FormatFloat(float64(moved)/float64(time.Millisecond), 'f', -1, 64), way) + case moved < time.Minute: + return fmt.Sprintf("%s seconds %s", strconv.FormatFloat(moved.Seconds(), 'f', -1, 64), way) + } + return moved.String() + " " + way +} + // localClock is the clock time an instant reads as, held as the same figures in UTC so it // can be stepped without a zone getting in the way. func localClock(at time.Time) time.Time { diff --git a/internal/timezone/wallclock_test.go b/internal/timezone/wallclock_test.go index b84d479e..77c9136c 100644 --- a/internal/timezone/wallclock_test.go +++ b/internal/timezone/wallclock_test.go @@ -36,6 +36,67 @@ func TestWallClockPlacesAClockTimeAsHEYDoes(t *testing.T) { if got := WallClock(day, clock, loc).UTC().Format(time.RFC3339); got != tt.want { t.Errorf("WallClock(%s %s) = %s, want %s", tt.day, tt.clock, got, tt.want) } + if got, ok := Placed(tt.day, tt.clock, loc); !ok || got.UTC().Format(time.RFC3339) != tt.want { + t.Errorf("Placed(%s %s) = %s, want %s", tt.day, tt.clock, got.UTC(), tt.want) + } }) } } + +// With no zone, HEY reads the clock as UTC; a date or a time that does not parse places +// nothing. +func TestPlacedWithoutAZoneIsUTC(t *testing.T) { + if got, ok := Placed("2026-11-01", "06:30", nil); !ok || !got.Equal(time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC)) { + t.Errorf("Placed = %s, want 06:30 UTC", got) + } + if _, ok := Placed("1 November", "06:30", nil); ok { + t.Error("Placed read a date that does not parse") + } + if _, ok := Placed("2026-11-01", "half six", nil); ok { + t.Error("Placed read a time that does not parse") + } +} + +// An instant can be sent as a clock time only when HEY places that clock back on it. These +// are checked against Time.zone.parse(clock).change(zone:) with Time.zone UTC. +func TestRepresentable(t *testing.T) { + newYork, _ := time.LoadLocation("America/New_York") + lordHowe, _ := time.LoadLocation("Australia/Lord_Howe") + for _, tt := range []struct { + name string + at time.Time + loc *time.Location + want bool + }{ + // 2026-11-01 01:30 America/New_York => 2026-11-01 05:30:00 UTC + {"the first 01:30 in New York", time.Date(2026, 11, 1, 5, 30, 0, 0, time.UTC), newYork, true}, + {"the second 01:30 in New York", time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC), newYork, false}, + // 2026-04-05 01:30 Australia/Lord_Howe => 2026-04-04 14:30:00 UTC + {"the first 01:30 at Lord Howe", time.Date(2026, 4, 4, 14, 30, 0, 0, time.UTC), lordHowe, true}, + {"the second 01:30 at Lord Howe", time.Date(2026, 4, 4, 15, 0, 0, 0, time.UTC), lordHowe, false}, + {"a time with seconds", time.Date(2026, 6, 10, 14, 0, 30, 0, time.UTC), newYork, false}, + {"an ordinary time", time.Date(2026, 6, 10, 14, 0, 0, 0, time.UTC), newYork, true}, + {"UTC", time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC), time.UTC, true}, + } { + if got := Representable(tt.at, tt.loc); got != tt.want { + t.Errorf("%s: Representable = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestMovedBy(t *testing.T) { + at := time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC) + for _, tt := range []struct { + sent time.Time + want string + }{ + {at.Add(-time.Hour), "an hour earlier"}, + {at.Add(2 * time.Hour), "2 hours later"}, + {at.Add(-30 * time.Minute), "30 minutes earlier"}, + {at.Add(30 * time.Second), "30 seconds later"}, + } { + if got := MovedBy(at, tt.sent); got != tt.want { + t.Errorf("MovedBy(%s) = %q, want %q", tt.sent.Sub(at), got, tt.want) + } + } +} From 109276dca797365a99e0d031f4b2c2ecacf64e46 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 15:13:20 -0400 Subject: [PATCH 08/20] Read a Local time as HEY does, remember what the reader chose, and sanitize zone names The date-time picker read a clock time on Local with time.ParseInLocation, which puts 02:30 on the morning New York springs forward at 01:30, so a time typed on Local was saved an hour from where HEY and hey event put it. Local is now read with timezone.WallClock like a named zone, on the picker's own local clock, a seam that lets a test stand the machine anywhere without moving time.Local. The picker now says whether the reader has answered it: typed into the date or the time, stepped the date, or chosen a zone, even the one already on it. The event form needs that to tell its own defaults from the reader's choices. A zone name can come from HEY, as an event's zone or the account's, and was drawn as it came, on the form and in the open list. Both now go through terminal.SanitizeLine, and the sink manifest lists the picker's zone fields so a raw write of them fails TestSinksAreSanitized. --- internal/cmd/testdata/sink_manifest.txt | 2 + internal/tui/datetime.go | 90 +++++++++++++++++++------ 2 files changed, 70 insertions(+), 22 deletions(-) diff --git a/internal/cmd/testdata/sink_manifest.txt b/internal/cmd/testdata/sink_manifest.txt index 844891f3..56e68dac 100644 --- a/internal/cmd/testdata/sink_manifest.txt +++ b/internal/cmd/testdata/sink_manifest.txt @@ -29,6 +29,8 @@ field AppUrl field UndoSendUrl field Purpose field Status +field zoneMatches +field zone sanitizer terminal.Sanitize sanitizer terminal.SanitizeLine diff --git a/internal/tui/datetime.go b/internal/tui/datetime.go index eaa2d928..49930f35 100644 --- a/internal/tui/datetime.go +++ b/internal/tui/datetime.go @@ -14,6 +14,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/basecamp/hey-cli/internal/terminal" "github.com/basecamp/hey-cli/internal/timezone" ) @@ -58,6 +59,16 @@ type dateTimePicker struct { zoneFilter textinput.Model zoneMatches []string zoneCursor int + + // local is the clock Local means: the machine's, and a seam for tests, which would + // otherwise have to move the whole process's time.Local to put the widget somewhere. + local *time.Location + + // touched says the reader has answered any of the widget's questions — typed in the date + // or the time, stepped the date, or chosen a zone, even the one already chosen — and + // opened is what it showed before they did. Between them they are what changed() asks. + touched bool + opened string } // newDateTimePicker starts on a moment. An all-day moment has no time of day and no zone, @@ -70,11 +81,30 @@ func newDateTimePicker(at time.Time, allDay bool) *dateTimePicker { allDay: allDay, zone: localZoneLabel, choices: zoneChoices(), + local: time.Local, } picker.setMoment(at) + picker.markOpened() return picker } +// markOpened takes what the widget shows now as what it opened with: a form that fills in +// a zone or a moment of its own after building the widget calls it again, so that the form's +// own answer is not mistaken for the reader's. +func (p *dateTimePicker) markOpened() { + p.touched = false + p.opened = p.shown() +} + +// changed is whether the reader has had a hand in what the widget says. +func (p *dateTimePicker) changed() bool { + return p.touched || p.shown() != p.opened +} + +func (p *dateTimePicker) shown() string { + return p.dateInput.Value() + "\x00" + p.timeInput.Value() + "\x00" + p.zone +} + // setMoment puts a date and a time of day on the widget, read on at's own clock. func (p *dateTimePicker) setMoment(at time.Time) { p.dateInput.SetValue(at.Format("2006-01-02")) @@ -136,29 +166,34 @@ func (p *dateTimePicker) zoneName() string { // moment is the date and time read as belonging to the zone chosen, which is what a form // comparing two of these wants. // -// A named zone is read the way HEY will read it, since HEY is sent the clock and places it -// itself: a time the clocks skip moves on to one that exists, and a time they repeat is the -// one HEY takes. Local is read the way the form converts it, which is what HEY is sent. +// A clock time is read the way HEY reads one, on Local as much as in a named zone, so the +// form and `hey event` agree on what was typed: a time the clocks skip moves on to one that +// exists (02:30 on the morning New York springs forward is 03:30), and a time they repeat is +// the one HEY takes. func (p *dateTimePicker) moment() (time.Time, bool) { if p.allDay { - at, err := time.ParseInLocation("2006-01-02", p.date(), time.Local) + at, err := time.ParseInLocation("2006-01-02", p.date(), p.local) return at, err == nil } + zone := p.clockZone() + if zone == nil { + return time.Time{}, false + } + return timezone.Placed(p.date(), p.clock(), zone) +} + +// clockZone is the zone the widget's clock time is read in: the one chosen, or the machine's +// for Local. A chosen name this build cannot load reads nowhere, and problem() says so. +func (p *dateTimePicker) clockZone() *time.Location { name := p.zoneName() if name == "" { - at, err := time.ParseInLocation("2006-01-02 15:04", p.date()+" "+p.clock(), time.Local) - return at, err == nil + return p.local } zone, ok := loadEventZone(name) if !ok { - return time.Time{}, false - } - day, dayErr := time.Parse("2006-01-02", p.date()) - clock, clockErr := time.Parse("15:04", p.clock()) - if dayErr != nil || clockErr != nil { - return time.Time{}, false + return nil } - return timezone.WallClock(day, clock, zone), true + return zone } // problem says the first thing wrong with what the reader filled in, and nothing when the @@ -253,13 +288,9 @@ func (p *dateTimePicker) handleKey(msg tea.KeyPressMsg) tea.Cmd { p.shiftDate(days) return nil } - var cmd tea.Cmd - p.dateInput, cmd = p.dateInput.Update(msg) - return cmd + return p.edit(&p.dateInput, msg) case dateTimeFieldTime: - var cmd tea.Cmd - p.timeInput, cmd = p.timeInput.Update(msg) - return cmd + return p.edit(&p.timeInput, msg) default: // A printable key both opens the list and starts the filter, so the reader can type // "zag" at the zone and land on Europe/Zagreb without first pressing anything. @@ -271,6 +302,19 @@ func (p *dateTimePicker) handleKey(msg tea.KeyPressMsg) tea.Cmd { } } +// edit hands a key to one of the text inputs, and counts it as the reader's answer when it +// changed what the input holds. Retyping a time the same as it was counts too: the value +// changes on the way. +func (p *dateTimePicker) edit(input *textinput.Model, msg tea.KeyPressMsg) tea.Cmd { + before := input.Value() + var cmd tea.Cmd + *input, cmd = input.Update(msg) + if input.Value() != before { + p.touched = true + } + return cmd +} + // dateStep is the day-at-a-time keys: the arrows, which are unbound in a single-line text // input. Nothing printable steps, because a date is typed as YYYY-MM-DD and every key that // can appear in one has to reach the input — a - that stepped instead could never be typed. @@ -293,6 +337,7 @@ func (p *dateTimePicker) shiftDate(days int) { return } p.dateInput.SetValue(at.AddDate(0, 0, days).Format("2006-01-02")) + p.touched = true } func (p *dateTimePicker) openZoneList() tea.Cmd { @@ -345,6 +390,7 @@ func (p *dateTimePicker) moveZoneCursor(delta int) { func (p *dateTimePicker) pickHighlightedZone() { if p.zoneCursor >= 0 && p.zoneCursor < len(p.zoneMatches) { p.zone = p.zoneMatches[p.zoneCursor] + p.touched = true } p.closeZoneList() } @@ -392,7 +438,7 @@ func (p *dateTimePicker) view() string { if !p.allDay { segments = append(segments, p.segment(dateTimeFieldTime, p.timeInput.View()), - p.segment(dateTimeFieldZone, p.zone)) + p.segment(dateTimeFieldZone, terminal.SanitizeLine(p.zone))) } line := strings.Join(segments, styleMuted.Render(" · ")) if !p.zoneOpen { @@ -428,9 +474,9 @@ func (p *dateTimePicker) zoneListView() string { for i := first; i < last; i++ { if i == p.zoneCursor { fmt.Fprintf(&b, "%s\n", lipgloss.NewStyle().Foreground(colorActive).Bold(true). - Render("› "+p.zoneMatches[i])) + Render("› "+terminal.SanitizeLine(p.zoneMatches[i]))) } else { - fmt.Fprintf(&b, " %s\n", p.zoneMatches[i]) + fmt.Fprintf(&b, " %s\n", terminal.SanitizeLine(p.zoneMatches[i])) } } if hidden := len(p.zoneMatches) - last; hidden > 0 { From 1bd8eb5b150734e890bff7dcdb78fc88376ec998 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 15:13:35 -0400 Subject: [PATCH 09/20] Refuse a save HEY would move, and give late account zones to all-day edits The form sent the clock it showed and checked the moments before they were written, so a save could move an event without a word. A title-only edit of an event ending at the second 01:30 of the night New York falls back sent 01:30 back, which HEY places at the first, an hour earlier; a Local end written on a zoned start's clock could land the same way. Every end is now checked as HEY will read what it is sent (timezone.Placed): an end the reader has not touched must come back as the instant the event already has, and one that would not is refused with how far it would move, as hey event edit refuses it. Retyping the time is choosing HEY's reading of it. The order check is on the placed instants too. A late identity read now gives an all-day event being edited the account's zone for the times it would get by being made timed, as the read would have had it landed first. Whether the reader has answered is read from the pickers themselves, so choosing Local, which leaves the form looking as it did, is no longer taken back when the read lands. A new event's default end is the first at least an hour after its start that HEY places where it is shown, so Lord Howe's half-hour fall-back gives 01:00 to 02:00 rather than a half-hour event. --- docs/tui.md | 5 +- internal/tui/event_form.go | 187 +++++++++++++++------ internal/tui/event_form_zone_test.go | 235 +++++++++++++++++++++++++-- 3 files changed, 361 insertions(+), 66 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index c1a56dcd..dcdf407a 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -154,6 +154,9 @@ when it does, unless you have already changed its times. The zone list still off (your machine's clock, saved without a zone) and the zones in your machine's zone database, or a shortlist of common zones where that database cannot be listed. An edit keeps the event's own zone, and an event saved without one stays on `Local`; an all-day event given a -time takes the account's zone. +time takes the account's zone. Times are read as HEY reads them, on `Local` too: a time the +clocks skip moves on to one that exists. Saving refuses a time HEY would put somewhere else — +one kept at the second of two moments as the clocks go back, or one with seconds — and asks +you to retype it rather than moving the event. In Calendar, press `a` to create a habit. Habits visible in the current calendar range can be selected with `[` and `]`, edited with `e`, and deleted by pressing `x` twice. Habit forms use Tab to move between fields and Ctrl+S to save. diff --git a/internal/tui/event_form.go b/internal/tui/event_form.go index 85e07dca..f1e90517 100644 --- a/internal/tui/event_form.go +++ b/internal/tui/event_form.go @@ -96,11 +96,17 @@ type eventForm struct { starts *dateTimePicker ends *dateTimePicker - // on is the day the form was opened on, and offered is when it offered the event, as - // schedule() reads it: together they are what lets a new event take the account's zone - // when the identity read naming it lands after the form opened — see adoptAccountZone. - on time.Time - offered string + // startsHad and endsHad are the instants a timed event being edited already has, and + // what an end the reader leaves alone means; they are zero for a new event and for an + // all-day one, whose ends are dates rather than instants. See wireMoment. + startsHad time.Time + endsHad time.Time + + // on is the day the form was opened on, and allDayArrived whether the event being edited + // was all day: what adoptAccountZone needs to give a form the account's zone when the + // identity read naming it lands after the form opened. + on time.Time + allDayArrived bool // chosenReminders runs alongside eventReminders, and notify is the one the arrows are on. chosenReminders []bool @@ -210,62 +216,67 @@ func newEventForm(mode eventFormMode, event Recording, on time.Time, calendars [ form.starts.setZoneName(startZone) form.ends.setZoneName(endZone) if mode == eventFormCreate { - form.offerAnHour(starts) + form.offerAnHour() } + if mode == eventFormEdit && !event.AllDay { + form.startsHad, form.endsHad = event.StartsAt, event.EndsAt + } + form.starts.markOpened() + form.ends.markOpened() form.on = on - form.offered = form.schedule() + form.allDayArrived = mode == eventFormEdit && event.AllDay return form } -// offerAnHour ends a new event an hour after it starts, as the end reads once it is sent. An -// hour on from the first 01:00 of the night New York falls back is the second 01:00, which -// reads — and is sent — as the same clock as the start, and HEY places both at the first: a -// zero-length event. The end moves on an hour at a time until it reads as after the start, -// which there is 02:00, an hour on the clock. -func (f *eventForm) offerAnHour(starts time.Time) { - ends := starts.Add(time.Hour) - f.ends.setMoment(ends) - begins, ok := f.starts.moment() +// offerAnHour ends a new event at least an hour after it starts, where HEY will place both: +// the first end from an hour on whose clock time HEY reads back as that same instant, a +// quarter of an hour at a time. An hour on from the first 01:00 of the night New York falls +// back is the second 01:00, which HEY reads as the first — a zero-length event — so the form +// offers 02:00; at Lord Howe, where the clocks go back half an hour, an hour on from 01:00 is +// the second 01:30, and the form offers 02:00 there too. +func (f *eventForm) offerAnHour() { + starts, ok := f.starts.moment() if !ok { return } - for range 3 { - if finishes, ok := f.ends.moment(); !ok || finishes.After(begins) { + clock := f.ends.clockZone() + if clock == nil { + clock = starts.Location() + } + for ends := starts.Add(time.Hour); ends.Before(starts.Add(48 * time.Hour)); ends = ends.Add(15 * time.Minute) { + if timezone.Representable(ends, clock) { + f.ends.setMoment(ends.In(clock)) return } - ends = ends.Add(time.Hour) - f.ends.setMoment(ends) } + f.ends.setMoment(starts.Add(time.Hour).In(clock)) } -// adoptAccountZone gives a new event the account's zone when the identity read that names it -// lands after the form opened on Local, offering the next whole hour on that clock instead. -// It does so only while the form still offers what it opened with: a reader who has touched -// the day, a time or a zone has answered the question already, and moving the times out from -// under them would be worse than Local. A zone that cannot be used changes nothing. +// adoptAccountZone gives the form the account's zone when the identity read that names it +// lands after the form opened on Local. A new event takes it and is offered the next whole +// hour on that clock; an all-day event being edited takes it for the times it would be given +// if it stopped being all day, as it would have had the read been first. Neither happens once +// the reader has touched a day, a time or a zone — choosing Local included — since they have +// answered the question already and moving the times out from under them would be worse than +// Local. A zone that cannot be used changes nothing. func (f *eventForm) adoptAccountZone(name string) { - if f.mode != eventFormCreate || f.schedule() != f.offered { + if f.starts.changed() || f.ends.changed() || (f.mode == eventFormEdit && !f.allDayArrived) { return } account, zone := usableZone(name) if zone == nil { return } - starts := newEventStart(f.on, zone) - f.starts.setMoment(starts) + if f.mode == eventFormCreate { + f.starts.setMoment(newEventStart(f.on, zone)) + } f.starts.setZoneName(account) f.ends.setZoneName(account) - f.offerAnHour(starts) - f.offered = f.schedule() -} - -// schedule is everything the form says about when the event is, as one string to compare. -func (f *eventForm) schedule() string { - return strings.Join([]string{ - strconv.FormatBool(f.allDay), - f.starts.date(), f.starts.timeInput.Value(), f.starts.zone, - f.ends.date(), f.ends.timeInput.Value(), f.ends.zone, - }, "\x00") + if f.mode == eventFormCreate { + f.offerAnHour() + } + f.starts.markOpened() + f.ends.markOpened() } // usableZone is the account's zone when HEY can look it up by that name, and nothing when it @@ -672,32 +683,82 @@ func (f *eventForm) values() eventFormValues { if f.allDay { return values } - values.StartsAt, values.StartTime, values.StartTimeZone = wireMoment(f.starts, f.ends.zoneName()) - values.EndsAt, values.EndTime, values.EndTimeZone = wireMoment(f.ends, f.starts.zoneName()) + starts, ends := f.wireEnds() + values.StartsAt, values.StartTime, values.StartTimeZone = starts.date, starts.clock, starts.zone + values.EndsAt, values.EndTime, values.EndTimeZone = ends.date, ends.clock, ends.zone return values } +// wireEnd is one end of a timed event as HEY is sent it, and the instant the form means by it. +type wireEnd struct { + date, clock, zone string + meant time.Time + known bool +} + +func (f *eventForm) wireEnds() (starts, ends wireEnd) { + return wireMoment(f.starts, f.startsHad, f.ends.zoneName()), wireMoment(f.ends, f.endsHad, f.starts.zoneName()) +} + // wireMoment is one moment as HEY should read it. A field the reader is still typing does not // parse; validate refuses the save before that matters, and until then the strings as typed // are the honest answer. // +// The moment meant is the one on the widget, read as HEY reads a clock time — except on an +// edit, where an end the reader has not touched means the instant the event already has. That +// is what validate holds the wire to: a kept end HEY would place somewhere else is refused +// rather than moved. +// // HEY keeps a zone for both ends of an event or for neither, and a write naming one zone is // given it for both. So a moment left on Local beside one with a zone — a new event whose end // the reader moved back to Local, say — cannot go as UTC: HEY would read it on the other end's -// clock. It is written on that clock instead, which is the same instant. -func wireMoment(p *dateTimePicker, other string) (date, clock, zone string) { +// clock. It is written on that clock instead. +func wireMoment(p *dateTimePicker, had time.Time, other string) wireEnd { + meant, known := p.moment() + if !had.IsZero() && !p.changed() { + meant, known = had, true + } if name := p.zoneName(); name != "" { - return p.date(), p.clock(), name + return wireEnd{date: p.date(), clock: p.clock(), zone: name, meant: meant, known: known} } - at, ok := p.moment() - if !ok { - return p.date(), p.clock(), "" + if !known { + return wireEnd{date: p.date(), clock: p.clock()} } + at, zone := meant.UTC(), "" if loc, ok := loadEventZone(other); ok { - at = at.In(loc) - return at.Format("2006-01-02"), at.Format("15:04"), other + at, zone = meant.In(loc), other } - return at.UTC().Format("2006-01-02"), at.UTC().Format("15:04"), "" + return wireEnd{date: at.Format("2006-01-02"), clock: at.Format("15:04"), zone: zone, meant: meant, known: true} +} + +// placed is where HEY puts the end it is sent. +func (w wireEnd) placed() (time.Time, bool) { + var loc *time.Location + if w.zone != "" { + zone, ok := loadEventZone(w.zone) + if !ok { + return time.Time{}, false + } + loc = zone + } + return timezone.Placed(w.date, w.clock, loc) +} + +// moveProblem is the refusal for an end HEY would place away from the moment meant: one with +// seconds, which HEY is never sent, or one at the second of two moments a clock time names as +// the clocks go back, where HEY takes the first. Retyping the time is the reader saying that +// HEY's reading of it is the one they want. +func (w wireEnd) moveProblem(label string, placed time.Time) string { + moved := timezone.MovedBy(w.meant, placed) + if !w.meant.Equal(w.meant.Truncate(time.Minute)) { + return fmt.Sprintf("%s — HEY is sent whole minutes, so saving would move it %s. Retype the time", label, moved) + } + zone := "UTC" + if w.zone != "" { + zone = terminal.SanitizeLine(w.zone) + } + return fmt.Sprintf("%s — HEY reads %s %s %s as another moment, so saving would move it %s. Retype the time or choose another", + label, w.date, w.clock, zone, moved) } // trixHTML is what the reader typed as the rich text HEY stores an event's notes as: escaped, @@ -777,11 +838,29 @@ func (f *eventForm) validate() string { return "Ends — " + problem } + if f.allDay { + starts, startsOK := f.starts.moment() + ends, endsOK := f.ends.moment() + if startsOK && endsOK && ends.Before(starts) { + return "The end is before the start" + } + return f.detailProblem() + } + + // What is checked is what HEY is sent, read the way HEY reads it: an end it would place + // away from the moment meant is refused rather than moved, and the order is HEY's order. // Eight in the morning in Auckland is the evening before in Madrid, so the two clocks on - // their own do not say which comes first — the moments do. - starts, startsOK := f.starts.moment() - ends, endsOK := f.ends.moment() - if startsOK && endsOK && ends.Before(starts) { + // their own do not say which comes first — the placed moments do. + starts, ends := f.wireEnds() + startsAt, startsOK := starts.placed() + endsAt, endsOK := ends.placed() + if startsOK && starts.known && !startsAt.Equal(starts.meant) { + return starts.moveProblem("Starts", startsAt) + } + if endsOK && ends.known && !endsAt.Equal(ends.meant) { + return ends.moveProblem("Ends", endsAt) + } + if startsOK && endsOK && endsAt.Before(startsAt) { return "The end is before the start" } return f.detailProblem() diff --git a/internal/tui/event_form_zone_test.go b/internal/tui/event_form_zone_test.go index 8e982443..2f3690ff 100644 --- a/internal/tui/event_form_zone_test.go +++ b/internal/tui/event_form_zone_test.go @@ -10,7 +10,11 @@ import ( "testing" "time" + tea "charm.land/bubbletea/v2" + hey "github.com/basecamp/hey-sdk/go/pkg/hey" + + "github.com/basecamp/hey-cli/internal/timezone" ) // These tests never set time.Local: every moment they build is in a zone named here, so @@ -111,6 +115,9 @@ func TestChoosingLocalOnANewEventSendsUTC(t *testing.T) { if choices := form.starts.choices; len(choices) < 3 || choices[0] != localZoneLabel { t.Fatalf("choices = %v, want Local first and the full list after it", choices) } + // The machine is put in Tokyo through the widget's own seam, so what Local means here does + // not depend on where the suite runs. + onMachine(form, mustZone(t, "Asia/Tokyo")) for _, picker := range []*dateTimePicker{form.starts, form.ends} { picker.focusField(dateTimeFieldZone) typeInto(t, picker, "local") @@ -120,15 +127,35 @@ func TestChoosingLocalOnANewEventSendsUTC(t *testing.T) { t.Fatalf("zones = %q → %q, want Local chosen", form.starts.zoneName(), form.ends.zoneName()) } - // The clock on the form is now read on the machine's, and goes as that instant in UTC. - starts, _ := time.ParseInLocation("2006-01-02 15:04", form.starts.date()+" "+form.starts.clock(), time.Local) + // The form still reads 04:00 → 05:00 on the 14th, now on Tokyo's clock, and it goes as UTC + // with no zone. HEY reads 2026-10-14 04:00 Asia/Tokyo as 2026-10-13 19:00 UTC. values := form.values() - if values.StartsAt != starts.UTC().Format("2006-01-02") || values.StartTime != starts.UTC().Format("15:04") { - t.Errorf("start = %q %q, want %s", values.StartsAt, values.StartTime, starts.UTC()) - } if values.StartTimeZone != "" || values.EndTimeZone != "" { t.Errorf("zones = %q → %q, want none — a moment on Local goes as UTC", values.StartTimeZone, values.EndTimeZone) } + wantPlaced(t, "start", values.StartsAt, values.StartTime, values.StartTimeZone, time.Date(2026, 10, 13, 19, 0, 0, 0, time.UTC)) + wantPlaced(t, "end", values.EndsAt, values.EndTime, values.EndTimeZone, time.Date(2026, 10, 13, 20, 0, 0, 0, time.UTC)) +} + +// onMachine puts both of a form's moments on a machine in zone, through the widget's seam. +func onMachine(form *eventForm, zone *time.Location) { + form.starts.local = zone + form.ends.local = zone +} + +// wantPlaced reads an end on the wire the way HEY reads it — the clock time in the zone sent +// with it, or UTC without one — and checks it is the instant wanted. The expectations are +// ActiveSupport's own: Time.zone = "UTC"; Time.zone.parse(clock).change(zone:).utc. +func wantPlaced(t *testing.T, end, date, clock, zone string, want time.Time) { + t.Helper() + var loc *time.Location + if zone != "" { + loc = mustZone(t, zone) + } + got, ok := timezone.Placed(date, clock, loc) + if !ok || !got.Equal(want) { + t.Errorf("%s sent as %s %s %q, which HEY places at %s, want %s", end, date, clock, zone, got.UTC(), want.UTC()) + } } // HEY keeps a zone for both ends or neither, so one end moved to Local beside the account's @@ -136,16 +163,92 @@ func TestChoosingLocalOnANewEventSendsUTC(t *testing.T) { func TestOneEndOnLocalIsWrittenInTheOtherEndsZone(t *testing.T) { on := time.Date(2026, 10, 14, 9, 41, 0, 0, mustZone(t, "Europe/Madrid")) form := newAccountZoneForm(eventFormCreate, Recording{}, on, indianapolis) + onMachine(form, time.UTC) + form.ends.setZoneName("") + form.ends.setMoment(time.Date(2026, 10, 14, 10, 15, 0, 0, time.UTC)) + + values := form.values() + if values.StartTimeZone != indianapolis || values.EndTimeZone != indianapolis { + t.Errorf("zones = %q → %q, want the account's for both", values.StartTimeZone, values.EndTimeZone) + } + // 2026-10-14 04:00 America/Indiana/Indianapolis => 08:00 UTC; the end is 10:15 UTC itself. + wantPlaced(t, "start", values.StartsAt, values.StartTime, values.StartTimeZone, time.Date(2026, 10, 14, 8, 0, 0, 0, time.UTC)) + wantPlaced(t, "end", values.EndsAt, values.EndTime, values.EndTimeZone, time.Date(2026, 10, 14, 10, 15, 0, 0, time.UTC)) + if got := form.validate(); got != "Name is required" { + t.Errorf("validate = %q, want only the missing name", got) + } +} + +// A Local end beside a zoned start is written on the start's clock, and at the second 01:30 of +// the night New York falls back that clock time is one HEY reads as the first: the form refuses +// rather than moving the end an hour. The first 01:30 goes through. +func TestALocalEndHEYWouldMoveIsRefused(t *testing.T) { + on := time.Date(2026, 11, 1, 0, 10, 0, 0, mustZone(t, "America/New_York")) + form := newAccountZoneForm(eventFormCreate, Recording{}, on, "America/New_York") + form.title.SetValue("Night shift handover") + onMachine(form, time.UTC) form.ends.setZoneName("") - ends, _ := time.ParseInLocation("2006-01-02 15:04", form.ends.date()+" "+form.ends.clock(), time.Local) - ends = ends.In(mustZone(t, indianapolis)) + // 06:30 UTC is the second 01:30 in New York; HEY reads 2026-11-01 01:30 as 05:30 UTC. + form.ends.setMoment(time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC)) + want := "Ends — HEY reads 2026-11-01 01:30 America/New_York as another moment, so saving would move it an hour earlier. Retype the time or choose another" + if got := form.validate(); got != want { + t.Errorf("validate = %q, want %q", got, want) + } + + form.ends.setMoment(time.Date(2026, 11, 1, 5, 30, 0, 0, time.UTC)) + if got := form.validate(); got != "" { + t.Errorf("validate = %q, want the first 01:30 taken", got) + } values := form.values() - if values.EndTimeZone != indianapolis || values.EndsAt != ends.Format("2006-01-02") || values.EndTime != ends.Format("15:04") { - t.Errorf("end = %q %q %q, want %s", values.EndsAt, values.EndTime, values.EndTimeZone, ends) + wantPlaced(t, "end", values.EndsAt, values.EndTime, values.EndTimeZone, time.Date(2026, 11, 1, 5, 30, 0, 0, time.UTC)) +} + +// A title-only edit sends the times back as they are shown, and an end HEY imported at the +// second 01:30 of the night New York falls back would come back at the first: the form refuses +// rather than moving it, as `hey event edit` does. Retyping the time is the reader choosing +// HEY's reading of it. +func TestEditingAnEventHEYWouldMoveIsRefusedUntilRetyped(t *testing.T) { + event := Recording{ + ID: 4821, Title: "Night shift handover", Type: "Calendar::Event", + StartsAt: time.Date(2026, 11, 1, 5, 0, 0, 0, time.UTC), // 01:00 EDT + EndsAt: time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC), // the second 01:30, EST + StartsAtZone: "America/New_York", EndsAtZone: "America/New_York", + } + form := newAccountZoneForm(eventFormEdit, event, time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC), indianapolis) + form.title.SetValue("Night shift handover, west door") + + want := "Ends — HEY reads 2026-11-01 01:30 America/New_York as another moment, so saving would move it an hour earlier. Retype the time or choose another" + if got := form.validate(); got != want { + t.Errorf("validate = %q, want %q", got, want) } - if values.StartTimeZone != indianapolis || values.StartTime != "04:00" { - t.Errorf("start = %q %q, want 04:00 in the account's zone", values.StartTime, values.StartTimeZone) + + form.ends.focusField(dateTimeFieldTime) + form.ends.handleKey(tea.KeyPressMsg{Code: tea.KeyBackspace}) + typeInto(t, form.ends, "0") + if got := form.validate(); got != "" { + t.Errorf("validate = %q, want a retyped time taken", got) + } + values := form.values() + // 2026-11-01 01:00 and 01:30 America/New_York => 05:00 and 05:30 UTC. + wantPlaced(t, "start", values.StartsAt, values.StartTime, values.StartTimeZone, time.Date(2026, 11, 1, 5, 0, 0, 0, time.UTC)) + wantPlaced(t, "end", values.EndsAt, values.EndTime, values.EndTimeZone, time.Date(2026, 11, 1, 5, 30, 0, 0, time.UTC)) +} + +// HEY is sent whole minutes, so an imported time with seconds cannot be kept by an edit that +// never touched it; the form says so rather than dropping them. +func TestEditingAnEventWithSecondsIsRefused(t *testing.T) { + event := Recording{ + ID: 4822, Title: "Standup", Type: "Calendar::Event", + StartsAt: time.Date(2026, 8, 20, 13, 30, 30, 0, time.UTC), + EndsAt: time.Date(2026, 8, 20, 13, 45, 0, 0, time.UTC), + } + form := newAccountZoneForm(eventFormEdit, event, time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC), indianapolis) + onMachine(form, time.UTC) + + want := "Starts — HEY is sent whole minutes, so saving would move it 30 seconds earlier. Retype the time" + if got := form.validate(); got != want { + t.Errorf("validate = %q, want %q", got, want) } } @@ -410,3 +513,113 @@ func TestEventFormOrdersASkippedTimeAsHEYPlacesIt(t *testing.T) { t.Errorf("validate = %q, want an end at the start HEY places taken", got) } } + +// A time typed on Local is read the way HEY and `hey event` read it too: on a machine in New +// York, 02:30 on the morning the clocks spring forward is 03:30, sent as 07:30 UTC, not an +// 01:30 Go would make of it. +func TestATimeTypedOnLocalIsReadAsHEYReadsIt(t *testing.T) { + newYork := mustZone(t, "America/New_York") + form := newAccountZoneForm(eventFormCreate, Recording{}, time.Date(2026, 3, 8, 0, 20, 0, 0, newYork), "") + onMachine(form, newYork) + form.title.SetValue("Night shift handover") + form.starts.timeInput.SetValue("02:30") + form.ends.timeInput.SetValue("04:00") + + if got := form.validate(); got != "" { + t.Fatalf("validate = %q, want the form taken", got) + } + values := form.values() + // 2026-03-08 02:30 and 04:00 America/New_York => 07:30 and 08:00 UTC. + wantPlaced(t, "start", values.StartsAt, values.StartTime, values.StartTimeZone, time.Date(2026, 3, 8, 7, 30, 0, 0, time.UTC)) + wantPlaced(t, "end", values.EndsAt, values.EndTime, values.EndTimeZone, time.Date(2026, 3, 8, 8, 0, 0, 0, time.UTC)) +} + +// An all-day event opened for editing before the identity read lands takes the account's zone +// when it does, for the times it would get by no longer being all day, as it would have had +// the read come first. +func TestAnAllDayEditTakesTheAccountZoneWhenTheIdentityLandsLate(t *testing.T) { + event := Recording{ + ID: 3, Title: "Offsite", Type: "Calendar::Event", AllDay: true, + StartsAt: time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC), + EndsAt: time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC), + } + form := newAccountZoneForm(eventFormEdit, event, time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC), "") + form.adoptAccountZone(indianapolis) + form.setAllDay(false) + + values := form.values() + if values.StartTimeZone != indianapolis || values.EndTimeZone != indianapolis || values.StartsAt != "2026-08-20" { + t.Errorf("made timed = %s %q → %q, want the day in the account's zone", + values.StartsAt, values.StartTimeZone, values.EndTimeZone) + } + + // A timed event keeps its own terms whenever the read lands. + timed := Recording{ + ID: 100, Title: "Standup", Type: "Calendar::Event", + StartsAt: time.Date(2026, 8, 20, 13, 30, 0, 0, time.UTC), + EndsAt: time.Date(2026, 8, 20, 13, 45, 0, 0, time.UTC), + } + form = newAccountZoneForm(eventFormEdit, timed, time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC), "") + form.adoptAccountZone(indianapolis) + if form.starts.zoneName() != "" || form.ends.zoneName() != "" { + t.Errorf("a zoneless event took the zones %q → %q", form.starts.zoneName(), form.ends.zoneName()) + } +} + +// Choosing Local is an answer even though it leaves the form showing what it showed, and an +// identity read landing after it does not take it back. +func TestChoosingLocalOutlastsALateIdentityRead(t *testing.T) { + form := newAccountZoneForm(eventFormCreate, Recording{}, time.Date(2026, 10, 14, 9, 41, 0, 0, time.UTC), "") + form.starts.focusField(dateTimeFieldZone) + typeInto(t, form.starts, "local") + form.starts.handleKey(keyPress("enter")) + shown := form.starts.clock() + + form.adoptAccountZone(indianapolis) + if form.starts.zoneName() != "" || form.ends.zoneName() != "" { + t.Errorf("zones = %q → %q, want Local kept", form.starts.zoneName(), form.ends.zoneName()) + } + if form.starts.clock() != shown { + t.Errorf("start = %s, want %s left as it was", form.starts.clock(), shown) + } +} + +// At Lord Howe the clocks go back half an hour, so an hour on from 01:00 is the second 01:30, +// which HEY reads as the first: a half-hour event. The form offers 02:00, the first end at +// least an hour on that HEY places where it is shown. +func TestNewEventFormRunsAnHourAcrossLordHowesHalfHourFallBack(t *testing.T) { + lordHowe := mustZone(t, "Australia/Lord_Howe") + form := newAccountZoneForm(eventFormCreate, Recording{}, time.Date(2026, 4, 5, 0, 41, 0, 0, lordHowe), "Australia/Lord_Howe") + + values := form.values() + if values.StartTime != "01:00" || values.EndTime != "02:00" { + t.Errorf("times = %s → %s, want 01:00 → 02:00", values.StartTime, values.EndTime) + } + // 2026-04-05 01:00 and 02:00 Australia/Lord_Howe => 2026-04-04 14:00 and 15:30 UTC. + wantPlaced(t, "start", values.StartsAt, values.StartTime, values.StartTimeZone, time.Date(2026, 4, 4, 14, 0, 0, 0, time.UTC)) + wantPlaced(t, "end", values.EndsAt, values.EndTime, values.EndTimeZone, time.Date(2026, 4, 4, 15, 30, 0, 0, time.UTC)) +} + +// A zone name comes from HEY — an event's own zone, the account's — and is shown with its +// escape sequences stripped, on the form and in the open list, while the name itself is kept +// for the checks and the write. +func TestZoneNamesAreShownSanitized(t *testing.T) { + const hostile = "Europe/Madrid\x1b]0;owned\x07" + form := newAccountZoneForm(eventFormCreate, Recording{}, time.Date(2026, 10, 14, 9, 41, 0, 0, time.UTC), "") + form.starts.setZoneName(hostile) + + if got := form.view(); strings.Contains(got, "\x1b]0;") { + t.Errorf("the form shows the zone's escape sequence: %q", got) + } + form.starts.focusField(dateTimeFieldZone) + typeInto(t, form.starts, "madrid") + if got := form.view(); strings.Contains(got, "\x1b]0;") { + t.Errorf("the zone list shows the escape sequence: %q", got) + } + if form.starts.zone != hostile { + t.Errorf("zone = %q, want the name kept as it came", form.starts.zone) + } + if got := form.starts.problem(); got != "That is not a time zone" { + t.Errorf("problem = %q, want the name refused", got) + } +} From d9cb34210a4525e3500bc1cf3da525a6fa602e46 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 15:38:39 -0400 Subject: [PATCH 10/20] Keep a tracked time's untouched ends as they arrived The tracked-time form read both ends off their pickers, and a picker shows a clock time: a track ending at the second 01:30 of the night New York falls back reads back as the first, now that the picker reads a Local time as HEY does. A save that changed only the category then sent the end an hour earlier. An end the reader has not touched is now the instant it arrived with, for the payload, the order check and the length shown. --- internal/tui/time_track_form.go | 23 +++++++++++++++++------ internal/tui/time_track_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/internal/tui/time_track_form.go b/internal/tui/time_track_form.go index 06a4facf..8e805888 100644 --- a/internal/tui/time_track_form.go +++ b/internal/tui/time_track_form.go @@ -250,8 +250,8 @@ func (f *timeTrackForm) validate() string { if problem := f.ends.problem(); problem != "" { return "Ends: " + problem } - starts, _ := f.starts.moment() - ends, _ := f.ends.moment() + starts, _ := f.when(f.starts, f.startsArrived) + ends, _ := f.when(f.ends, f.endsArrived) if ends.Before(starts) { return "It cannot end before it starts" } @@ -264,17 +264,28 @@ func (f *timeTrackForm) validate() string { return "" } +// when is one end of the track: the instant it arrived with while the reader has left that +// end alone, and what the picker reads once they have not. A picker shows a clock time, and +// the second 01:30 of a night the clocks go back reads back as the first, so reading an +// untouched end off the picker would move it on a save that only changed the category. +func (f *timeTrackForm) when(p *dateTimePicker, arrived time.Time) (time.Time, bool) { + if !p.changed() { + return arrived, true + } + return p.moment() +} + // payload is what changed, and nothing else: an omitted field is a field HEY leaves alone. func (f *timeTrackForm) payload() (generated.UpdateTimeTrackPayload, bool) { var payload generated.UpdateTimeTrackPayload changed := false - if starts, ok := f.starts.moment(); ok && !starts.Equal(f.startsArrived.Truncate(time.Minute)) { + if starts, ok := f.when(f.starts, f.startsArrived); ok && !starts.Truncate(time.Minute).Equal(f.startsArrived.Truncate(time.Minute)) { utc := starts.UTC() payload.StartsAt = &utc changed = true } - if ends, ok := f.ends.moment(); ok && !ends.Equal(f.endsArrived.Truncate(time.Minute)) { + if ends, ok := f.when(f.ends, f.endsArrived); ok && !ends.Truncate(time.Minute).Equal(f.endsArrived.Truncate(time.Minute)) { utc := ends.UTC() payload.EndsAt = &utc changed = true @@ -343,8 +354,8 @@ func (f *timeTrackForm) caution() string { // length and spanLine are the track as the list shows it, kept in front of the reader while // they move its ends around. func (f *timeTrackForm) length() time.Duration { - starts, startsOK := f.starts.moment() - ends, endsOK := f.ends.moment() + starts, startsOK := f.when(f.starts, f.startsArrived) + ends, endsOK := f.when(f.ends, f.endsArrived) if !startsOK || !endsOK { return 0 } diff --git a/internal/tui/time_track_test.go b/internal/tui/time_track_test.go index 542eb2e1..ad783724 100644 --- a/internal/tui/time_track_test.go +++ b/internal/tui/time_track_test.go @@ -597,6 +597,39 @@ func TestTrackedTimeEditFormSendsOnlyWhatChanged(t *testing.T) { } } +// A track that ran through the hour the clocks repeat keeps its instants when only its category +// changes. Its end at the second 01:30 of the night New York falls back shows as 01:30, which +// reads back as the first; an end the reader never touched is the one it arrived with, and a +// category-only save sends no times at all. +func TestTrackedTimeFormKeepsAnUntouchedRepeatedTime(t *testing.T) { + newYork, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatal(err) + } + track := trackedTime{ + ID: 7301, Category: "Client work", Notes: "On call", + StartsAt: time.Date(2026, 11, 1, 5, 0, 0, 0, time.UTC).In(newYork), // 01:00 EDT + EndsAt: time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC).In(newYork), // the second 01:30, EST + } + form := newTimeTrackForm(track, nil) + form.starts.local, form.ends.local = newYork, newYork + form.category.SetValue("Planning") + + if got := form.validate(); got != "" { + t.Errorf("validate = %q, want the track taken", got) + } + if got := form.length(); got != 90*time.Minute { + t.Errorf("length = %s, want the hour and a half it ran", got) + } + payload, changed := form.payload() + if !changed || payload.CategoryTitle != "Planning" { + t.Errorf("payload = %+v, want the category", payload) + } + if payload.StartsAt != nil || payload.EndsAt != nil { + t.Errorf("payload moves the track: starts %v ends %v", payload.StartsAt, payload.EndsAt) + } +} + // The category field cannot un-file a track, so a blank one leaves it alone — and the form says // as much rather than offering something HEY will ignore. func TestTrackedTimeFormWillNotUnfileATrack(t *testing.T) { From b5314b264b2be73800febafbe3e4440b24a9aaeb Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 15:53:47 -0400 Subject: [PATCH 11/20] Find a new event's default end by clock time, not by elapsed step The default end stepped a quarter of an hour of elapsed time at a time from an hour after the start and took the first instant a clock time could name. A start in a zone whose offset kept seconds is between whole minutes itself: Monrovia's 23:00 on 6 January 1972 was 23:44:30 UTC, every step kept the half minute, none could be sent, and the fallback offered 00:44, which HEY moves an hour on because the clocks skipped it, making a two-hour event. timezone.FirstClockFrom searches clock times a minute at a time instead, for the first that HEY places no earlier than an hour after the start and places where it reads. Monrovia's is 00:45; New York's and Lord Howe's fall-backs still give 01:00 to 02:00. It replaces timezone.Representable, which nothing else used. --- internal/timezone/wallclock.go | 25 ++++++++++++---- internal/timezone/wallclock_test.go | 45 +++++++++++++++++----------- internal/tui/event_form.go | 20 +++++-------- internal/tui/event_form_zone_test.go | 19 ++++++++++++ 4 files changed, 73 insertions(+), 36 deletions(-) diff --git a/internal/timezone/wallclock.go b/internal/timezone/wallclock.go index c40ac721..2efbc3e3 100644 --- a/internal/timezone/wallclock.go +++ b/internal/timezone/wallclock.go @@ -42,12 +42,25 @@ func Placed(date, clock string, loc *time.Location) (time.Time, bool) { return WallClock(day, at, loc), true } -// Representable is whether an instant can be sent to HEY as a clock time in loc and come -// back as itself: false for the second of two moments a clock time names as the clocks go -// back, when HEY takes the first, and for an instant between whole minutes. -func Representable(at time.Time, loc *time.Location) bool { - on := at.In(loc) - return at.Equal(at.Truncate(time.Minute)) && WallClock(on, on, loc).Equal(at) +// FirstClockFrom is the earliest clock time in loc, in whole minutes, that HEY places no +// earlier than at and places where it reads — the instant it places it at. Clock times are +// searched rather than instants, a minute at a time: an instant between whole minutes, as +// the start of anything in a zone that kept seconds in its offset is, cannot be sent at all, +// and neither can a time the clocks skip (HEY moves it on an hour) or the second of two they +// repeat (HEY takes the first). +func FirstClockFrom(at time.Time, loc *time.Location) time.Time { + wall := localClock(at.In(loc)) + if !wall.Equal(wall.Truncate(time.Minute)) { + wall = wall.Truncate(time.Minute).Add(time.Minute) + } + for range 3 * 24 * 60 { + placed := WallClock(wall, wall, loc) + if !placed.Before(at) && localClock(placed.In(loc)).Equal(wall) { + return placed + } + wall = wall.Add(time.Minute) + } + return at } // MovedBy says how far and which way an instant would move, from had to sent. diff --git a/internal/timezone/wallclock_test.go b/internal/timezone/wallclock_test.go index 77c9136c..7e679c18 100644 --- a/internal/timezone/wallclock_test.go +++ b/internal/timezone/wallclock_test.go @@ -23,6 +23,9 @@ func TestWallClockPlacesAClockTimeAsHEYDoes(t *testing.T) { {name: "an ordinary day", zone: "America/New_York", day: "2026-06-10", clock: "10:00", want: "2026-06-10T14:00:00Z"}, {name: "Almaty repeats 23:30 for good", zone: "Asia/Almaty", day: "2024-02-29", clock: "23:30", want: "2024-02-29T18:30:00Z"}, {name: "Volgograd repeats 01:30 for good", zone: "Europe/Volgograd", day: "2020-12-27", clock: "01:30", want: "2020-12-26T22:30:00Z"}, + {name: "Monrovia before its half minute went", zone: "Africa/Monrovia", day: "1972-01-06", clock: "23:00", want: "1972-01-06T23:44:30Z"}, + {name: "Monrovia skips 00:44", zone: "Africa/Monrovia", day: "1972-01-07", clock: "00:44", want: "1972-01-07T01:44:00Z"}, + {name: "Monrovia's first 00:45", zone: "Africa/Monrovia", day: "1972-01-07", clock: "00:45", want: "1972-01-07T00:45:00Z"}, {name: "Moscow repeats 01:30 for good", zone: "Europe/Moscow", day: "2014-10-26", clock: "01:30", want: "2014-10-25T22:30:00Z"}, } for _, tt := range tests { @@ -57,29 +60,35 @@ func TestPlacedWithoutAZoneIsUTC(t *testing.T) { } } -// An instant can be sent as a clock time only when HEY places that clock back on it. These -// are checked against Time.zone.parse(clock).change(zone:) with Time.zone UTC. -func TestRepresentable(t *testing.T) { - newYork, _ := time.LoadLocation("America/New_York") - lordHowe, _ := time.LoadLocation("Australia/Lord_Howe") +// The first clock time HEY places no earlier than an instant, and where it reads. Each +// expectation is checked against Time.zone.parse(clock).change(zone:) with Time.zone UTC. +func TestFirstClockFrom(t *testing.T) { for _, tt := range []struct { name string + zone string at time.Time - loc *time.Location - want bool + want time.Time }{ - // 2026-11-01 01:30 America/New_York => 2026-11-01 05:30:00 UTC - {"the first 01:30 in New York", time.Date(2026, 11, 1, 5, 30, 0, 0, time.UTC), newYork, true}, - {"the second 01:30 in New York", time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC), newYork, false}, - // 2026-04-05 01:30 Australia/Lord_Howe => 2026-04-04 14:30:00 UTC - {"the first 01:30 at Lord Howe", time.Date(2026, 4, 4, 14, 30, 0, 0, time.UTC), lordHowe, true}, - {"the second 01:30 at Lord Howe", time.Date(2026, 4, 4, 15, 0, 0, 0, time.UTC), lordHowe, false}, - {"a time with seconds", time.Date(2026, 6, 10, 14, 0, 30, 0, time.UTC), newYork, false}, - {"an ordinary time", time.Date(2026, 6, 10, 14, 0, 0, 0, time.UTC), newYork, true}, - {"UTC", time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC), time.UTC, true}, + // 2026-11-01 01:00 America/New_York => 05:00 UTC; an hour on is 06:00 UTC, the second + // 01:00, which HEY reads as the first. 02:00 => 07:00 UTC is the first after it. + {"New York's repeated hour", "America/New_York", time.Date(2026, 11, 1, 6, 0, 0, 0, time.UTC), time.Date(2026, 11, 1, 7, 0, 0, 0, time.UTC)}, + // 2026-04-05 01:00 Australia/Lord_Howe => 2026-04-04 14:00 UTC; 15:00 UTC is the second + // 01:30. 02:00 => 15:30 UTC. + {"Lord Howe's repeated half hour", "Australia/Lord_Howe", time.Date(2026, 4, 4, 15, 0, 0, 0, time.UTC), time.Date(2026, 4, 4, 15, 30, 0, 0, time.UTC)}, + // 1972-01-06 23:00 Africa/Monrovia => 23:44:30 UTC; an hour on is 00:44:30, and the + // clock had just dropped its half minute. 00:44 => 01:44 UTC (a skipped time moved an + // hour on); 00:45 => 00:45 UTC. + {"Monrovia's half minute", "Africa/Monrovia", time.Date(1972, 1, 7, 0, 44, 30, 0, time.UTC), time.Date(1972, 1, 7, 0, 45, 0, 0, time.UTC)}, + // 2026-03-08 03:00 America/New_York => 07:00 UTC, the first clock time after the gap. + {"New York's skipped hour", "America/New_York", time.Date(2026, 3, 8, 7, 0, 0, 0, time.UTC), time.Date(2026, 3, 8, 7, 0, 0, 0, time.UTC)}, + {"an ordinary time", "America/New_York", time.Date(2026, 6, 10, 14, 0, 0, 0, time.UTC), time.Date(2026, 6, 10, 14, 0, 0, 0, time.UTC)}, } { - if got := Representable(tt.at, tt.loc); got != tt.want { - t.Errorf("%s: Representable = %v, want %v", tt.name, got, tt.want) + loc, err := time.LoadLocation(tt.zone) + if err != nil { + t.Fatal(err) + } + if got := FirstClockFrom(tt.at, loc); !got.Equal(tt.want) { + t.Errorf("%s: FirstClockFrom(%s) = %s, want %s", tt.name, tt.at, got.UTC(), tt.want) } } } diff --git a/internal/tui/event_form.go b/internal/tui/event_form.go index f1e90517..4c2eb959 100644 --- a/internal/tui/event_form.go +++ b/internal/tui/event_form.go @@ -229,11 +229,13 @@ func newEventForm(mode eventFormMode, event Recording, on time.Time, calendars [ } // offerAnHour ends a new event at least an hour after it starts, where HEY will place both: -// the first end from an hour on whose clock time HEY reads back as that same instant, a -// quarter of an hour at a time. An hour on from the first 01:00 of the night New York falls -// back is the second 01:00, which HEY reads as the first — a zero-length event — so the form -// offers 02:00; at Lord Howe, where the clocks go back half an hour, an hour on from 01:00 is -// the second 01:30, and the form offers 02:00 there too. +// the first clock time on the end's clock that HEY places an hour or more after the start, +// where it reads (timezone.FirstClockFrom). An hour on from the first 01:00 of the night New +// York falls back is the second 01:00, which HEY reads as the first — a zero-length event — so +// the form offers 02:00; at Lord Howe, where the clocks go back half an hour, an hour on from +// 01:00 is the second 01:30, and the form offers 02:00 there too. Monrovia's 23:00 on 6 January +// 1972 was 23:44:30 UTC, and an hour on is 00:44:30 on a clock that had just dropped its half +// minute; the form offers 00:45. func (f *eventForm) offerAnHour() { starts, ok := f.starts.moment() if !ok { @@ -243,13 +245,7 @@ func (f *eventForm) offerAnHour() { if clock == nil { clock = starts.Location() } - for ends := starts.Add(time.Hour); ends.Before(starts.Add(48 * time.Hour)); ends = ends.Add(15 * time.Minute) { - if timezone.Representable(ends, clock) { - f.ends.setMoment(ends.In(clock)) - return - } - } - f.ends.setMoment(starts.Add(time.Hour).In(clock)) + f.ends.setMoment(timezone.FirstClockFrom(starts.Add(time.Hour), clock).In(clock)) } // adoptAccountZone gives the form the account's zone when the identity read that names it diff --git a/internal/tui/event_form_zone_test.go b/internal/tui/event_form_zone_test.go index 2f3690ff..ee617c41 100644 --- a/internal/tui/event_form_zone_test.go +++ b/internal/tui/event_form_zone_test.go @@ -623,3 +623,22 @@ func TestZoneNamesAreShownSanitized(t *testing.T) { t.Errorf("problem = %q, want the name refused", got) } } + +// Monrovia's clocks kept thirty seconds in their offset until 7 January 1972: 23:00 on the 6th +// was 23:44:30 UTC, and at midnight they jumped to 00:44:30 UTC. An hour on from that start is +// 00:44:30, which no clock time names; 00:44 is one HEY moves an hour on (01:44 UTC), so the +// first end at least an hour on is 00:45 (=> 00:45 UTC). +func TestNewEventFormRunsAnHourAcrossMonroviasHalfMinute(t *testing.T) { + monrovia := mustZone(t, "Africa/Monrovia") + form := newAccountZoneForm(eventFormCreate, Recording{}, time.Date(1972, 1, 6, 22, 30, 0, 0, monrovia), "Africa/Monrovia") + + values := form.values() + if values.StartTime != "23:00" || values.EndsAt != "1972-01-07" || values.EndTime != "00:45" { + t.Errorf("times = %s %s → %s %s, want 23:00 → 00:45 the next day", values.StartsAt, values.StartTime, values.EndsAt, values.EndTime) + } + wantPlaced(t, "start", values.StartsAt, values.StartTime, values.StartTimeZone, time.Date(1972, 1, 6, 23, 44, 30, 0, time.UTC)) + wantPlaced(t, "end", values.EndsAt, values.EndTime, values.EndTimeZone, time.Date(1972, 1, 7, 0, 45, 0, 0, time.UTC)) + if got := form.validate(); got != "Name is required" { + t.Errorf("validate = %q, want only the missing name", got) + } +} From dfa23f5e3c3a0e0bad49bebf3805a89e737a43c5 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 15:54:10 -0400 Subject: [PATCH 12/20] Keep an end that shows what it opened with, and let All day answer Whether an end kept the instant it arrived with was asked of the reader's touch rather than of the value: choosing the zone already chosen, typing a digit and taking it back, or stepping the date there and back sent the end as HEY reads its clock. An end at the second 01:30 of the night New York falls back then went back at the first, 06:30 UTC saved as 05:30 with no word, and a time with seconds lost them. The tracked-time form did the same on a category-only save. Both now keep an end's instant while its date, time and zone show what they opened with, however the reader got back there; only a real change is read as HEY reads it. A kept end HEY cannot be sent as it is is still refused, and since typing the same clock back no longer counts as retyping it, the refusal says the way out: choose another time, or press ctrl+s again, with nothing pressed in between, to save it where HEY reads it. A late identity read no longer changes the days of a new form whose reader switched All day. On a UTC machine at 23:30 on the 14th the form offers the 15th; taking Los Angeles' zone after the switch moved it to the 14th. The switch now counts as an answer, as a chosen zone does. --- docs/tui.md | 11 ++- internal/tui/datetime.go | 22 +++-- internal/tui/event_form.go | 90 ++++++++++++++------ internal/tui/event_form_zone_test.go | 121 +++++++++++++++++++++++---- internal/tui/time_track_form.go | 11 +-- internal/tui/time_track_test.go | 46 ++++++++++ 6 files changed, 243 insertions(+), 58 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index dcdf407a..f1efe817 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -150,13 +150,16 @@ A new event's times are written in your HEY account's time zone, the one HEY's w `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, the form opens on `Local` instead, and a form opened before the read answers takes the zone -when it does, unless you have already changed its times. The zone list still offers `Local` +when it does, unless you have already edited a date or a time, chosen a zone, or switched +All day. The zone list still offers `Local` (your machine's clock, saved without a zone) and the zones in your machine's zone database, or a shortlist of common zones where that database cannot be listed. An edit keeps the event's own zone, and an event saved without one stays on `Local`; an all-day event given a time takes the account's zone. Times are read as HEY reads them, on `Local` too: a time the -clocks skip moves on to one that exists. Saving refuses a time HEY would put somewhere else — -one kept at the second of two moments as the clocks go back, or one with seconds — and asks -you to retype it rather than moving the event. +clocks skip moves on to one that exists. A time you leave showing what it opened with keeps +the moment it had, even if you typed at it and took it back. Saving refuses one HEY would put +somewhere else — at the second of two moments as the clocks go back, or with seconds — rather +than moving the event: choose another time, or press Ctrl+S again to save it where HEY reads it. +The tracked-time form keeps an unchanged time the same way. In Calendar, press `a` to create a habit. Habits visible in the current calendar range can be selected with `[` and `]`, edited with `e`, and deleted by pressing `x` twice. Habit forms use Tab to move between fields and Ctrl+S to save. diff --git a/internal/tui/datetime.go b/internal/tui/datetime.go index 49930f35..8376cdfd 100644 --- a/internal/tui/datetime.go +++ b/internal/tui/datetime.go @@ -64,9 +64,10 @@ type dateTimePicker struct { // otherwise have to move the whole process's time.Local to put the widget somewhere. local *time.Location - // touched says the reader has answered any of the widget's questions — typed in the date - // or the time, stepped the date, or chosen a zone, even the one already chosen — and - // opened is what it showed before they did. Between them they are what changed() asks. + // opened is what the widget showed before the reader had a hand in it — the date, the + // time and the zone — and touched says they have had one: typed in the date or the time, + // stepped the date, or chosen a zone, even the one already chosen. They answer two + // different questions; see moved() and answered(). touched bool opened string } @@ -96,9 +97,18 @@ func (p *dateTimePicker) markOpened() { p.opened = p.shown() } -// changed is whether the reader has had a hand in what the widget says. -func (p *dateTimePicker) changed() bool { - return p.touched || p.shown() != p.opened +// moved is whether the widget says something other than what it opened with. A form keeps the +// instant an end arrived with for as long as it does not: choosing the zone already chosen, +// typing and taking it back, or stepping the date there and back leave the end where it was. +func (p *dateTimePicker) moved() bool { + return p.shown() != p.opened +} + +// answered is whether the reader has had a hand in the widget at all, even to leave it as it +// was. Choosing Local on a form that opened on Local is an answer, and nothing the form learns +// afterwards should take it back. +func (p *dateTimePicker) answered() bool { + return p.touched || p.moved() } func (p *dateTimePicker) shown() string { diff --git a/internal/tui/event_form.go b/internal/tui/event_form.go index 4c2eb959..f6de8f05 100644 --- a/internal/tui/event_form.go +++ b/internal/tui/event_form.go @@ -107,6 +107,14 @@ type eventForm struct { // identity read naming it lands after the form opened. on time.Time allDayArrived bool + // allDaySwitched says the reader has switched All day, which answers what the form's days + // are as surely as typing one does. + allDaySwitched bool + + // movesAccepted is the refusal a save was last met with when it would have moved an end + // HEY cannot be sent as it is; ctrl+s again, with nothing pressed in between, saves it + // where HEY reads it. See handleKey. + movesAccepted string // chosenReminders runs alongside eventReminders, and notify is the one the arrows are on. chosenReminders []bool @@ -252,11 +260,11 @@ func (f *eventForm) offerAnHour() { // lands after the form opened on Local. A new event takes it and is offered the next whole // hour on that clock; an all-day event being edited takes it for the times it would be given // if it stopped being all day, as it would have had the read been first. Neither happens once -// the reader has touched a day, a time or a zone — choosing Local included — since they have -// answered the question already and moving the times out from under them would be worse than -// Local. A zone that cannot be used changes nothing. +// the reader has edited a date or a time, chosen a zone — Local included — or switched All +// day, since they have answered the question already and moving the days or times out from +// under them would be worse than Local. A zone that cannot be used changes nothing. func (f *eventForm) adoptAccountZone(name string) { - if f.starts.changed() || f.ends.changed() || (f.mode == eventFormEdit && !f.allDayArrived) { + if f.starts.answered() || f.ends.answered() || f.allDaySwitched || (f.mode == eventFormEdit && !f.allDayArrived) { return } account, zone := usableZone(name) @@ -701,9 +709,9 @@ func (f *eventForm) wireEnds() (starts, ends wireEnd) { // are the honest answer. // // The moment meant is the one on the widget, read as HEY reads a clock time — except on an -// edit, where an end the reader has not touched means the instant the event already has. That -// is what validate holds the wire to: a kept end HEY would place somewhere else is refused -// rather than moved. +// edit, where an end still showing what it opened with means the instant the event already +// has, whatever the reader did on the way back to it. That is what validate holds the wire +// to: a kept end HEY would place somewhere else is refused rather than moved. // // HEY keeps a zone for both ends of an event or for neither, and a write naming one zone is // given it for both. So a moment left on Local beside one with a zone — a new event whose end @@ -711,7 +719,7 @@ func (f *eventForm) wireEnds() (starts, ends wireEnd) { // clock. It is written on that clock instead. func wireMoment(p *dateTimePicker, had time.Time, other string) wireEnd { meant, known := p.moment() - if !had.IsZero() && !p.changed() { + if !had.IsZero() && !p.moved() { meant, known = had, true } if name := p.zoneName(); name != "" { @@ -740,21 +748,20 @@ func (w wireEnd) placed() (time.Time, bool) { return timezone.Placed(w.date, w.clock, loc) } -// moveProblem is the refusal for an end HEY would place away from the moment meant: one with -// seconds, which HEY is never sent, or one at the second of two moments a clock time names as -// the clocks go back, where HEY takes the first. Retyping the time is the reader saying that -// HEY's reading of it is the one they want. +// moveProblem is the refusal for an end HEY would place away from the moment meant, saying how +// far and what to do: choose another time, or save again to take HEY's reading of this one. func (w wireEnd) moveProblem(label string, placed time.Time) string { + const choice = "Choose another time, or press ctrl+s again to save it there" moved := timezone.MovedBy(w.meant, placed) if !w.meant.Equal(w.meant.Truncate(time.Minute)) { - return fmt.Sprintf("%s — HEY is sent whole minutes, so saving would move it %s. Retype the time", label, moved) + return fmt.Sprintf("%s — HEY is sent whole minutes, so saving would move it %s. %s", label, moved, choice) } zone := "UTC" if w.zone != "" { zone = terminal.SanitizeLine(w.zone) } - return fmt.Sprintf("%s — HEY reads %s %s %s as another moment, so saving would move it %s. Retype the time or choose another", - label, w.date, w.clock, zone, moved) + return fmt.Sprintf("%s — HEY reads %s %s %s as another moment, so saving would move it %s. %s", + label, w.date, w.clock, zone, moved, choice) } // trixHTML is what the reader typed as the rich text HEY stores an event's notes as: escaped, @@ -843,23 +850,39 @@ func (f *eventForm) validate() string { return f.detailProblem() } - // What is checked is what HEY is sent, read the way HEY reads it: an end it would place - // away from the moment meant is refused rather than moved, and the order is HEY's order. - // Eight in the morning in Auckland is the evening before in Madrid, so the two clocks on - // their own do not say which comes first — the placed moments do. + // What is checked is what HEY is sent, read the way HEY reads it, so the order is HEY's + // order. Eight in the morning in Auckland is the evening before in Madrid, so the two + // clocks on their own do not say which comes first — the placed moments do. starts, ends := f.wireEnds() startsAt, startsOK := starts.placed() endsAt, endsOK := ends.placed() - if startsOK && starts.known && !startsAt.Equal(starts.meant) { - return starts.moveProblem("Starts", startsAt) - } - if endsOK && ends.known && !endsAt.Equal(ends.meant) { - return ends.moveProblem("Ends", endsAt) - } if startsOK && endsOK && endsAt.Before(startsAt) { return "The end is before the start" } - return f.detailProblem() + if problem := f.detailProblem(); problem != "" { + return problem + } + // Last, because it is the one refusal a reader can overrule: everything else has to be + // right before a second ctrl+s saves an end where HEY reads it. + return f.moveProblem() +} + +// moveProblem refuses an end HEY would place away from the moment the form means by it: a +// kept end with seconds, which HEY is never sent, or one kept at the second of two moments a +// clock time names as the clocks go back, where HEY takes the first. It is nothing for an +// all-day event, whose ends are dates. +func (f *eventForm) moveProblem() string { + if f.allDay { + return "" + } + starts, ends := f.wireEnds() + if at, ok := starts.placed(); ok && starts.known && !at.Equal(starts.meant) { + return starts.moveProblem("Starts", at) + } + if at, ok := ends.placed(); ok && ends.known && !at.Equal(ends.meant) { + return ends.moveProblem("Ends", at) + } + return "" } // detailProblem is the first thing wrong behind the More row. It is asked separately so that a @@ -909,6 +932,9 @@ func (f *eventForm) handleKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { if f.saving { return nil, false } + if msg.String() != "ctrl+s" { + f.movesAccepted = "" + } if f.capturesKeys() { return f.picker(f.focus).handleKey(msg), false } @@ -922,12 +948,21 @@ func (f *eventForm) handleKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { case msg.Key().Code == tea.KeyEnter && f.focus != eventFieldNotes: return f.step(1), false case msg.String() == "ctrl+s": - if problem := f.validate(); problem != "" { + // A kept end HEY would place elsewhere is refused once, saying where; the same save + // asked for again, with nothing touched in between, is the reader choosing that. The + // form has no other way to say it, since typing the same clock back is not a change. + problem := f.validate() + accepted := problem != "" && problem == f.movesAccepted + f.movesAccepted = "" + if problem != "" && !accepted { f.status = problem f.isError = true if f.detailProblem() != "" { f.revealed = true } + if problem == f.moveProblem() { + f.movesAccepted = problem + } return nil, false } f.saving = true @@ -946,6 +981,7 @@ func (f *eventForm) handleKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { case eventFieldAllDay: if isSpace(msg) { f.setAllDay(!f.allDay) + f.allDaySwitched = true } case eventFieldMore: if isSpace(msg) { diff --git a/internal/tui/event_form_zone_test.go b/internal/tui/event_form_zone_test.go index ee617c41..cd3d4bba 100644 --- a/internal/tui/event_form_zone_test.go +++ b/internal/tui/event_form_zone_test.go @@ -191,7 +191,7 @@ func TestALocalEndHEYWouldMoveIsRefused(t *testing.T) { // 06:30 UTC is the second 01:30 in New York; HEY reads 2026-11-01 01:30 as 05:30 UTC. form.ends.setMoment(time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC)) - want := "Ends — HEY reads 2026-11-01 01:30 America/New_York as another moment, so saving would move it an hour earlier. Retype the time or choose another" + want := "Ends — HEY reads 2026-11-01 01:30 America/New_York as another moment, so saving would move it an hour earlier. Choose another time, or press ctrl+s again to save it there" if got := form.validate(); got != want { t.Errorf("validate = %q, want %q", got, want) } @@ -204,34 +204,92 @@ func TestALocalEndHEYWouldMoveIsRefused(t *testing.T) { wantPlaced(t, "end", values.EndsAt, values.EndTime, values.EndTimeZone, time.Date(2026, 11, 1, 5, 30, 0, 0, time.UTC)) } -// A title-only edit sends the times back as they are shown, and an end HEY imported at the -// second 01:30 of the night New York falls back would come back at the first: the form refuses -// rather than moving it, as `hey event edit` does. Retyping the time is the reader choosing -// HEY's reading of it. -func TestEditingAnEventHEYWouldMoveIsRefusedUntilRetyped(t *testing.T) { - event := Recording{ +// A night shift handover imported with its end at the second 01:30 of the night New York +// falls back: 06:30 UTC, which HEY reads 2026-11-01 01:30 America/New_York back as 05:30. +func repeatedHourEvent() Recording { + return Recording{ ID: 4821, Title: "Night shift handover", Type: "Calendar::Event", StartsAt: time.Date(2026, 11, 1, 5, 0, 0, 0, time.UTC), // 01:00 EDT EndsAt: time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC), // the second 01:30, EST StartsAtZone: "America/New_York", EndsAtZone: "America/New_York", } - form := newAccountZoneForm(eventFormEdit, event, time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC), indianapolis) - form.title.SetValue("Night shift handover, west door") +} - want := "Ends — HEY reads 2026-11-01 01:30 America/New_York as another moment, so saving would move it an hour earlier. Retype the time or choose another" - if got := form.validate(); got != want { - t.Errorf("validate = %q, want %q", got, want) +const repeatedHourRefusal = "Ends — HEY reads 2026-11-01 01:30 America/New_York as another moment, so saving would move it an hour earlier. Choose another time, or press ctrl+s again to save it there" + +// A title-only edit sends the times back as they are shown, and the end would come back at the +// first 01:30: the form refuses rather than moving it, as `hey event edit` does. An end that +// shows what it opened with keeps its instant however the reader got back there — choosing the +// zone it was on, typing and taking it back, stepping the date there and back — so none of +// those moves it either. +func TestEditingAnEventHEYWouldMoveIsRefused(t *testing.T) { + for _, tt := range []struct { + name string + fiddle func(*eventForm) + }{ + {"left alone", func(*eventForm) {}}, + {"its zone chosen again", func(f *eventForm) { + f.ends.focusField(dateTimeFieldZone) + f.ends.handleKey(keyPress("enter")) + f.ends.handleKey(keyPress("enter")) + }}, + {"a digit typed and taken back", func(f *eventForm) { + f.ends.focusField(dateTimeFieldTime) + f.ends.handleKey(tea.KeyPressMsg{Code: tea.KeyBackspace}) + typeInto(t, f.ends, "0") + }}, + {"its date stepped on and back", func(f *eventForm) { + f.ends.focusField(dateTimeFieldDate) + f.ends.handleKey(keyPress("up")) + f.ends.handleKey(keyPress("down")) + }}, + } { + form := newAccountZoneForm(eventFormEdit, repeatedHourEvent(), time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC), indianapolis) + form.title.SetValue("Night shift handover, west door") + tt.fiddle(form) + + if form.ends.date() != "2026-11-01" || form.ends.clock() != "01:30" || form.ends.zoneName() != "America/New_York" { + t.Fatalf("%s: the end shows %s %s %s, want it as it opened", tt.name, form.ends.date(), form.ends.clock(), form.ends.zoneName()) + } + if got := form.validate(); got != repeatedHourRefusal { + t.Errorf("%s: validate = %q, want the end refused rather than moved from 06:30 UTC", tt.name, got) + } } +} +// Changing the end is the reader writing a new clock time, which is read as HEY reads it. +// 2026-11-01 01:45 America/New_York => 05:45 UTC. +func TestEditingAnEventToAnotherTimeIsReadAsHEYReadsIt(t *testing.T) { + form := newAccountZoneForm(eventFormEdit, repeatedHourEvent(), time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC), indianapolis) form.ends.focusField(dateTimeFieldTime) form.ends.handleKey(tea.KeyPressMsg{Code: tea.KeyBackspace}) - typeInto(t, form.ends, "0") + form.ends.handleKey(tea.KeyPressMsg{Code: tea.KeyBackspace}) + typeInto(t, form.ends, "45") + if got := form.validate(); got != "" { - t.Errorf("validate = %q, want a retyped time taken", got) + t.Fatalf("validate = %q, want a new time taken", got) } values := form.values() - // 2026-11-01 01:00 and 01:30 America/New_York => 05:00 and 05:30 UTC. wantPlaced(t, "start", values.StartsAt, values.StartTime, values.StartTimeZone, time.Date(2026, 11, 1, 5, 0, 0, 0, time.UTC)) + wantPlaced(t, "end", values.EndsAt, values.EndTime, values.EndTimeZone, time.Date(2026, 11, 1, 5, 45, 0, 0, time.UTC)) +} + +// The refusal can be overruled: ctrl+s again, with nothing pressed in between, saves the end +// where HEY reads it. Any other key asks again. +func TestSavingAgainTakesHEYsReadingOfAKeptEnd(t *testing.T) { + form := newAccountZoneForm(eventFormEdit, repeatedHourEvent(), time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC), indianapolis) + + if _, save := form.handleKey(keyPress("ctrl+s")); save || form.status != repeatedHourRefusal { + t.Fatalf("first ctrl+s saved=%v status=%q, want the refusal", save, form.status) + } + form.handleKey(keyPress("tab")) + if _, save := form.handleKey(keyPress("ctrl+s")); save { + t.Fatal("ctrl+s after another key saved without asking again") + } + if _, save := form.handleKey(keyPress("ctrl+s")); !save { + t.Fatalf("a second ctrl+s did not save: %q", form.status) + } + values := form.values() wantPlaced(t, "end", values.EndsAt, values.EndTime, values.EndTimeZone, time.Date(2026, 11, 1, 5, 30, 0, 0, time.UTC)) } @@ -246,10 +304,19 @@ func TestEditingAnEventWithSecondsIsRefused(t *testing.T) { form := newAccountZoneForm(eventFormEdit, event, time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC), indianapolis) onMachine(form, time.UTC) - want := "Starts — HEY is sent whole minutes, so saving would move it 30 seconds earlier. Retype the time" + want := "Starts — HEY is sent whole minutes, so saving would move it 30 seconds earlier. Choose another time, or press ctrl+s again to save it there" if got := form.validate(); got != want { t.Errorf("validate = %q, want %q", got, want) } + + // Typing at the start and taking it back leaves it showing what it opened with, and the + // seconds it has are still not dropped without a word. + form.starts.focusField(dateTimeFieldTime) + form.starts.handleKey(tea.KeyPressMsg{Code: tea.KeyBackspace}) + typeInto(t, form.starts, "0") + if got := form.validate(); got != want { + t.Errorf("after typing and taking it back, validate = %q, want %q", got, want) + } } // An edit keeps the event's own terms rather than the account's, as `hey event edit` does: a @@ -624,6 +691,28 @@ func TestZoneNamesAreShownSanitized(t *testing.T) { } } +// Switching All day is an answer about the form's days, so an identity read landing after it +// leaves the days the reader saw. On a UTC machine at 23:30 on the 14th, a new event is offered +// midnight on the 15th; Los Angeles is still on the 14th, and taking its zone then would move +// the all-day event the reader had just made back a day. +func TestSwitchingAllDayOutlastsALateIdentityRead(t *testing.T) { + form := newAccountZoneForm(eventFormCreate, Recording{}, time.Date(2026, 10, 14, 23, 30, 0, 0, time.UTC), "") + onMachine(form, time.UTC) + focusOn(form, eventFieldAllDay) + form.handleKey(keyPress(" ")) + if !form.allDay || form.starts.date() != "2026-10-15" { + t.Fatalf("all day = %v on %s, want the 15th all day", form.allDay, form.starts.date()) + } + + form.adoptAccountZone("America/Los_Angeles") + if values := form.values(); values.StartsAt != "2026-10-15" { + t.Errorf("starts on %s, want the 15th the reader saw", values.StartsAt) + } + if form.starts.zoneName() != "" { + t.Errorf("zone = %q, want Local left", form.starts.zoneName()) + } +} + // Monrovia's clocks kept thirty seconds in their offset until 7 January 1972: 23:00 on the 6th // was 23:44:30 UTC, and at midnight they jumped to 00:44:30 UTC. An hour on from that start is // 00:44:30, which no clock time names; 00:44 is one HEY moves an hour on (01:44 UTC), so the diff --git a/internal/tui/time_track_form.go b/internal/tui/time_track_form.go index 8e805888..a19ceb65 100644 --- a/internal/tui/time_track_form.go +++ b/internal/tui/time_track_form.go @@ -264,12 +264,13 @@ func (f *timeTrackForm) validate() string { return "" } -// when is one end of the track: the instant it arrived with while the reader has left that -// end alone, and what the picker reads once they have not. A picker shows a clock time, and -// the second 01:30 of a night the clocks go back reads back as the first, so reading an -// untouched end off the picker would move it on a save that only changed the category. +// when is one end of the track: the instant it arrived with while the picker still shows what +// it opened with, and what the picker reads once it shows something else. A picker shows a +// clock time, and the second 01:30 of a night the clocks go back reads back as the first, so +// reading an unchanged end off the picker would move it on a save that only changed the +// category — however the reader got back to the value it opened with. func (f *timeTrackForm) when(p *dateTimePicker, arrived time.Time) (time.Time, bool) { - if !p.changed() { + if !p.moved() { return arrived, true } return p.moment() diff --git a/internal/tui/time_track_test.go b/internal/tui/time_track_test.go index ad783724..813beb41 100644 --- a/internal/tui/time_track_test.go +++ b/internal/tui/time_track_test.go @@ -630,6 +630,52 @@ func TestTrackedTimeFormKeepsAnUntouchedRepeatedTime(t *testing.T) { } } +// An end the reader went back and forth on, but left showing what it opened with, is the end it +// arrived with: choosing Local again on a picker already on Local, or typing a digit and taking +// it back, sends no time on a category-only save. +func TestTrackedTimeFormKeepsARepeatedTimeTheReaderLeftAsItWas(t *testing.T) { + newYork, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatal(err) + } + track := trackedTime{ + ID: 7301, Category: "Client work", Notes: "On call", + StartsAt: time.Date(2026, 11, 1, 5, 0, 0, 0, time.UTC).In(newYork), // 01:00 EDT + EndsAt: time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC).In(newYork), // the second 01:30, EST + } + for _, tt := range []struct { + name string + fiddle func(*timeTrackForm) + }{ + {"Local chosen again", func(f *timeTrackForm) { + f.ends.focusField(dateTimeFieldZone) + typeInto(t, f.ends, "local") + f.ends.handleKey(keyPress("enter")) + }}, + {"a digit typed and taken back", func(f *timeTrackForm) { + f.ends.focusField(dateTimeFieldTime) + f.ends.handleKey(tea.KeyPressMsg{Code: tea.KeyBackspace}) + typeInto(t, f.ends, "0") + }}, + } { + form := newTimeTrackForm(track, nil) + form.starts.local, form.ends.local = newYork, newYork + tt.fiddle(form) + form.category.SetValue("Planning") + + if form.ends.clock() != "01:30" || form.ends.zoneName() != "" { + t.Fatalf("%s: the end shows %s %q, want it as it opened", tt.name, form.ends.clock(), form.ends.zoneName()) + } + payload, _ := form.payload() + if payload.StartsAt != nil || payload.EndsAt != nil { + t.Errorf("%s: payload moves the track: starts %v ends %v", tt.name, payload.StartsAt, payload.EndsAt) + } + if got := form.length(); got != 90*time.Minute { + t.Errorf("%s: length = %s, want the hour and a half it ran", tt.name, got) + } + } +} + // The category field cannot un-file a track, so a blank one leaves it alone — and the form says // as much rather than offering something HEY will ignore. func TestTrackedTimeFormWillNotUnfileATrack(t *testing.T) { From 7f2b590e136f0866632a1b73b30857ca50f18d70 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 15:56:22 -0400 Subject: [PATCH 13/20] Take back the digit the seconds test typed over, whatever the machine's clock shows TestEditingAnEventWithSecondsIsRefused typed a 0 back over the last digit of a start shown on the machine's own clock, which under Chatham's +12:45 ends in 5, so the test changed the time instead of undoing a keystroke. It now types back the digit it took. --- internal/tui/event_form_zone_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/tui/event_form_zone_test.go b/internal/tui/event_form_zone_test.go index cd3d4bba..7bbbd09b 100644 --- a/internal/tui/event_form_zone_test.go +++ b/internal/tui/event_form_zone_test.go @@ -311,9 +311,11 @@ func TestEditingAnEventWithSecondsIsRefused(t *testing.T) { // Typing at the start and taking it back leaves it showing what it opened with, and the // seconds it has are still not dropped without a word. + // The start shows on the machine's own clock, so the digit taken off is whatever it reads. + shown := form.starts.clock() form.starts.focusField(dateTimeFieldTime) form.starts.handleKey(tea.KeyPressMsg{Code: tea.KeyBackspace}) - typeInto(t, form.starts, "0") + typeInto(t, form.starts, shown[len(shown)-1:]) if got := form.validate(); got != want { t.Errorf("after typing and taking it back, validate = %q, want %q", got, want) } From 375c8d7b75517c2c7cbed1ea8fb03cd277198da7 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 16:10:54 -0400 Subject: [PATCH 14/20] Offer a new event an hour still to come at the second of a repeated hour At 01:00:00 on the second pass of the night New York falls back, 06:00 UTC, the next whole hour on the clock is the hour it is already at, and HEY reads 2026-11-01 01:00 America/New_York as the first, 05:00 UTC: the form offered a start an hour in the past. On the clock's own day the default start is now the first whole hour HEY places no earlier than now, 02:00 there. A day in view the clock has left or not reached keeps that day's hour, as before. --- internal/tui/event_form.go | 17 +++++++++++++++- internal/tui/event_form_zone_test.go | 29 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/internal/tui/event_form.go b/internal/tui/event_form.go index f6de8f05..7e04f42c 100644 --- a/internal/tui/event_form.go +++ b/internal/tui/event_form.go @@ -442,7 +442,22 @@ func newEventStart(on time.Time, zone *time.Location) time.Time { } // Written in UTC first so that hour 24 rolls over to the next day and nothing else moves. wall := time.Date(on.Year(), on.Month(), on.Day(), hour, 0, 0, 0, time.UTC) - return timezone.WallClock(wall, wall, zone).In(zone) + start := timezone.WallClock(wall, wall, zone) + + // On the clock's own day the hour offered is one still to come. At the second 01:00 of the + // night New York falls back, 01:00 is a whole hour on the clock, and HEY places it at the + // first, an hour ago; the next one it places after now is 02:00. On a day in view that the + // clock has already left or not yet reached, the hour is the day's, wherever that falls. + if clock.Format("2006-01-02") == on.Format("2006-01-02") { + for range 48 { + if !start.Before(on) { + break + } + wall = wall.Add(time.Hour) + start = timezone.WallClock(wall, wall, zone) + } + } + return start.In(zone) } // indexOfCalendar finds the calendar an event is filed on. The id is the answer where the diff --git a/internal/tui/event_form_zone_test.go b/internal/tui/event_form_zone_test.go index 7bbbd09b..a13bb5e4 100644 --- a/internal/tui/event_form_zone_test.go +++ b/internal/tui/event_form_zone_test.go @@ -733,3 +733,32 @@ func TestNewEventFormRunsAnHourAcrossMonroviasHalfMinute(t *testing.T) { t.Errorf("validate = %q, want only the missing name", got) } } + +// At the second 01:00 of the night New York falls back — 06:00 UTC — 01:00 is a whole hour on +// the clock, but HEY reads 2026-11-01 01:00 America/New_York as 05:00 UTC, an hour ago. The +// next whole hour it places after now is 02:00 (=> 07:00 UTC), and the hour runs to 03:00 +// (=> 08:00 UTC). +func TestNewEventFormStartsAfterNowAtASecondRepeatedHour(t *testing.T) { + newYork := mustZone(t, "America/New_York") + form := newAccountZoneForm(eventFormCreate, Recording{}, time.Date(2026, 11, 1, 6, 0, 0, 0, time.UTC).In(newYork), "America/New_York") + + values := form.values() + if values.StartTime != "02:00" || values.EndTime != "03:00" { + t.Errorf("times = %s → %s, want 02:00 → 03:00", values.StartTime, values.EndTime) + } + wantPlaced(t, "start", values.StartsAt, values.StartTime, values.StartTimeZone, time.Date(2026, 11, 1, 7, 0, 0, 0, time.UTC)) + wantPlaced(t, "end", values.EndsAt, values.EndTime, values.EndTimeZone, time.Date(2026, 11, 1, 8, 0, 0, 0, time.UTC)) +} + +// Lord Howe's clocks go back half an hour, from 02:00 to 01:30, so no whole hour repeats: at the +// second 01:30 (15:00 UTC) the next whole hour is 02:00 (=> 2026-04-04 15:30 UTC), after now. +func TestNewEventFormStartsAfterNowInLordHowesRepeatedHalfHour(t *testing.T) { + lordHowe := mustZone(t, "Australia/Lord_Howe") + form := newAccountZoneForm(eventFormCreate, Recording{}, time.Date(2026, 4, 4, 15, 0, 0, 0, time.UTC).In(lordHowe), "Australia/Lord_Howe") + + values := form.values() + if values.StartTime != "02:00" { + t.Errorf("start = %s, want 02:00", values.StartTime) + } + wantPlaced(t, "start", values.StartsAt, values.StartTime, values.StartTimeZone, time.Date(2026, 4, 4, 15, 30, 0, 0, time.UTC)) +} From affb94fdb230489047b2c145cc9a982f62b51825 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 16:10:54 -0400 Subject: [PATCH 15/20] Leave a form the reader has saved as they saw it when the account's zone lands A new event opened while the identity read was still out, then saved, took the account's zone the moment the read landed: the form rewrote its times and zone while the write it had already sent carried the Local ones, so the screen and the saved event disagreed, and a failed save handed the reader back values they never saw. Pressing ctrl+s now counts as accepting the form as it stands, whether the save goes out, fails, or is refused, and a form that is saving is never touched. --- internal/tui/event_form.go | 13 +++-- internal/tui/event_form_zone_test.go | 73 ++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/internal/tui/event_form.go b/internal/tui/event_form.go index 7e04f42c..fa39f628 100644 --- a/internal/tui/event_form.go +++ b/internal/tui/event_form.go @@ -108,8 +108,11 @@ type eventForm struct { on time.Time allDayArrived bool // allDaySwitched says the reader has switched All day, which answers what the form's days - // are as surely as typing one does. + // are as surely as typing one does. submitted says they have pressed ctrl+s: the form as it + // stood then is what they asked to save, what a failed save hands back to them, and not + // something a late identity read may rewrite underneath a write already on its way. allDaySwitched bool + submitted bool // movesAccepted is the refusal a save was last met with when it would have moved an end // HEY cannot be sent as it is; ctrl+s again, with nothing pressed in between, saves it @@ -262,9 +265,12 @@ func (f *eventForm) offerAnHour() { // if it stopped being all day, as it would have had the read been first. Neither happens once // the reader has edited a date or a time, chosen a zone — Local included — or switched All // day, since they have answered the question already and moving the days or times out from -// under them would be worse than Local. A zone that cannot be used changes nothing. +// under them would be worse than Local. Nor once they have pressed ctrl+s, saving or not: the +// form they saved is the form they keep, and one whose write is on its way must go on showing +// what it is writing. A zone that cannot be used changes nothing. func (f *eventForm) adoptAccountZone(name string) { - if f.starts.answered() || f.ends.answered() || f.allDaySwitched || (f.mode == eventFormEdit && !f.allDayArrived) { + if f.submitted || f.saving || f.starts.answered() || f.ends.answered() || f.allDaySwitched || + (f.mode == eventFormEdit && !f.allDayArrived) { return } account, zone := usableZone(name) @@ -966,6 +972,7 @@ func (f *eventForm) handleKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { // A kept end HEY would place elsewhere is refused once, saying where; the same save // asked for again, with nothing touched in between, is the reader choosing that. The // form has no other way to say it, since typing the same clock back is not a change. + f.submitted = true problem := f.validate() accepted := problem != "" && problem == f.movesAccepted f.movesAccepted = "" diff --git a/internal/tui/event_form_zone_test.go b/internal/tui/event_form_zone_test.go index a13bb5e4..02bc496e 100644 --- a/internal/tui/event_form_zone_test.go +++ b/internal/tui/event_form_zone_test.go @@ -734,6 +734,79 @@ func TestNewEventFormRunsAnHourAcrossMonroviasHalfMinute(t *testing.T) { } } +// An identity read that lands while a new event is being saved leaves the form as it is: the +// write already carries what the form showed, and the form goes on showing it, so the screen +// and the saved event agree. +func TestALateIdentityReadLeavesASavingFormAlone(t *testing.T) { + v, recorded := calendarWithEventServer(t) + v.Init() + identity := v.fetchIdentity()() + + v.HandleContentKey(keyPress("a")) + v.eventForm.title.SetValue("Design review") + shown := v.eventForm.values() + save := v.HandleContentKey(keyPress("ctrl+s")) + if save == nil || !v.eventForm.saving { + t.Fatal("ctrl+s did not start the save") + } + + v.Update(identity) + if got := v.eventForm.values(); got.StartsAt != shown.StartsAt || got.StartTime != shown.StartTime || + got.StartTimeZone != shown.StartTimeZone || got.EndTimeZone != shown.EndTimeZone { + t.Errorf("the saving form now shows %s %s %q → %q, want %s %s %q → %q as it was saved", + got.StartsAt, got.StartTime, got.StartTimeZone, got.EndTimeZone, + shown.StartsAt, shown.StartTime, shown.StartTimeZone, shown.EndTimeZone) + } + + if msg, ok := save().(calendarMutationMsg); !ok || msg.err != nil { + t.Fatalf("save = %T %v", msg, msg.err) + } + requests, bodies := recorded.snapshot() + var body string + for i, request := range requests { + if request == "POST /calendar/events.json" { + body = bodies[i] + } + } + form, err := url.ParseQuery(body) + if err != nil || body == "" { + t.Fatalf("no create was sent: %v %v", requests, err) + } + if form.Get("calendar_event[starts_at]") != shown.StartsAt || form.Get("calendar_event[starts_at_time]") != shown.StartTime+":00" || + form.Get("calendar_event[starts_at_time_zone_name]") != shown.StartTimeZone { + t.Errorf("the write carried %s %s %q, want what the form showed: %s %s %q", + form.Get("calendar_event[starts_at]"), form.Get("calendar_event[starts_at_time]"), form.Get("calendar_event[starts_at_time_zone_name]"), + shown.StartsAt, shown.StartTime, shown.StartTimeZone) + } +} + +// Pressing ctrl+s is accepting the form as it stands, whether the save is refused or goes out +// and fails: the reader is handed back what they saw, not a form a late identity read rewrote. +func TestASubmittedFormKeepsWhatTheReaderSaw(t *testing.T) { + on := time.Date(2026, 10, 14, 9, 41, 0, 0, time.UTC) + + // Refused: there is no name yet. + form := newAccountZoneForm(eventFormCreate, Recording{}, on, "") + form.handleKey(keyPress("ctrl+s")) + shown := form.starts.clock() + form.adoptAccountZone(indianapolis) + if form.starts.zoneName() != "" || form.starts.clock() != shown { + t.Errorf("after a refused save the form moved to %s %q", form.starts.clock(), form.starts.zoneName()) + } + + // Sent, and failed: the view hands the form back with saving off. + form = newAccountZoneForm(eventFormCreate, Recording{}, on, "") + form.title.SetValue("Design review") + if _, save := form.handleKey(keyPress("ctrl+s")); !save { + t.Fatalf("ctrl+s did not save: %q", form.status) + } + form.saving = false + form.adoptAccountZone(indianapolis) + if form.starts.zoneName() != "" || form.starts.clock() != shown { + t.Errorf("after a failed save the form moved to %s %q", form.starts.clock(), form.starts.zoneName()) + } +} + // At the second 01:00 of the night New York falls back — 06:00 UTC — 01:00 is a whole hour on // the clock, but HEY reads 2026-11-01 01:00 America/New_York as 05:00 UTC, an hour ago. The // next whole hour it places after now is 02:00 (=> 07:00 UTC), and the hour runs to 03:00 From d462dba3241315872db9cf776bd88eccf3e35b07 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 16:26:54 -0400 Subject: [PATCH 16/20] Offer an hour still to come on a pinned view that has become today A new event was offered the next whole hour after the moment the view was moved to its day, which the view keeps as its anchor. Press n at 09:15 to look at tomorrow, leave the TUI open until 15:10 the next day, and the form opened on that now-current day offered 10:00, hours gone. When the day in view is today, the form now starts from the view's clock read now; any other day keeps the hour it carries. --- internal/tui/calendar.go | 14 +++++++++++++- internal/tui/event_form_zone_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index 42e3eab8..5ea405c6 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -2111,7 +2111,7 @@ func (v *calendarView) startEventForm(mode eventFormMode, event Recording) tea.C return notifyError("Cannot add an event", errNoCalendars) } v.editing = event - v.eventForm = newEventForm(mode, event, v.day(), fileable, v.newEventCalendarID(fileable), v.accountZone, v.vc.styles) + v.eventForm = newEventForm(mode, event, v.newEventDay(), fileable, v.newEventCalendarID(fileable), v.accountZone, v.vc.styles) // An edit is handed what the event already carries, and this is load-bearing rather than a // courtesy: HEY clears the notes, location, link and attached email on any write that @@ -2129,6 +2129,18 @@ func (v *calendarView) startEventForm(mode eventFormMode, event Recording) tea.C return v.eventForm.init() } +// newEventDay is the moment a new event is offered from: the day in view, carrying the clock +// it had when the view was moved there. A pinned view that has become today — `n` to +// tomorrow at 09:15, the TUI left open until the next afternoon — would offer an hour long +// gone, so today is read off the clock now instead. +func (v *calendarView) newEventDay() time.Time { + now := v.now() + if day := v.day(); !sameDay(day, now) { + return day + } + return now +} + // saveEvent writes what the form is holding, including which calendar it is on: an update // takes a calendar the same way a create does, so stepping the form's picker moves the event. func (v *calendarView) saveEvent() tea.Cmd { diff --git a/internal/tui/event_form_zone_test.go b/internal/tui/event_form_zone_test.go index 02bc496e..9bfbc563 100644 --- a/internal/tui/event_form_zone_test.go +++ b/internal/tui/event_form_zone_test.go @@ -835,3 +835,29 @@ func TestNewEventFormStartsAfterNowInLordHowesRepeatedHalfHour(t *testing.T) { } wantPlaced(t, "start", values.StartsAt, values.StartTime, values.StartTimeZone, time.Date(2026, 4, 4, 15, 30, 0, 0, time.UTC)) } + +// A view pinned to a day that has since become today offers the next whole hour of now, not of +// the moment it was pinned: `n` to tomorrow at 09:15, left open until 15:10 the next day, and a +// new event there opens at 16:00. A day that is not today keeps its own hour. +func TestAPinnedViewThatBecameTodayOffersAnHourStillToCome(t *testing.T) { + v, _ := calendarWithEventServer(t) + v.now = func() time.Time { return time.Date(2026, 8, 20, 9, 15, 0, 0, time.Local) } + v.HandleContentKey(keyPress("n")) // 2026-08-21, pinned at 09:15 on the 20th + now := time.Date(2026, 8, 21, 15, 10, 0, 0, time.Local) + v.now = func() time.Time { return now } + + v.HandleContentKey(keyPress("a")) + if got := v.eventForm.starts.date() + " " + v.eventForm.starts.clock(); got != "2026-08-21 16:00" { + t.Errorf("start = %s, want 2026-08-21 16:00, the next whole hour after now", got) + } + if starts, ok := v.eventForm.starts.moment(); !ok || starts.Before(now) { + t.Errorf("start %s is before now %s", starts, now) + } + + v.eventForm = nil + v.HandleContentKey(keyPress("n")) // 2026-08-22, still carrying the pinned 09:15 + v.HandleContentKey(keyPress("a")) + if got := v.eventForm.starts.date() + " " + v.eventForm.starts.clock(); got != "2026-08-22 10:00" { + t.Errorf("start = %s, want 2026-08-22 10:00, the day's own hour", got) + } +} From 09c1b5955654ebb7927d5cab6729b22178205c8d Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 16:26:54 -0400 Subject: [PATCH 17/20] Test a failed save through the view's own failure path The failed-save case set the form's saving flag off by hand. It now saves against a server that refuses the create, feeds the answer through the calendar view's calendarMutationMsg handling, and only then lands the identity read, so a change to how the view hands a failed form back is caught too. --- internal/tui/event_form_zone_test.go | 103 ++++++++++++++++++--------- 1 file changed, 69 insertions(+), 34 deletions(-) diff --git a/internal/tui/event_form_zone_test.go b/internal/tui/event_form_zone_test.go index 9bfbc563..216c8594 100644 --- a/internal/tui/event_form_zone_test.go +++ b/internal/tui/event_form_zone_test.go @@ -794,46 +794,52 @@ func TestASubmittedFormKeepsWhatTheReaderSaw(t *testing.T) { t.Errorf("after a refused save the form moved to %s %q", form.starts.clock(), form.starts.zoneName()) } - // Sent, and failed: the view hands the form back with saving off. - form = newAccountZoneForm(eventFormCreate, Recording{}, on, "") - form.title.SetValue("Design review") - if _, save := form.handleKey(keyPress("ctrl+s")); !save { - t.Fatalf("ctrl+s did not save: %q", form.status) - } - form.saving = false - form.adoptAccountZone(indianapolis) - if form.starts.zoneName() != "" || form.starts.clock() != shown { - t.Errorf("after a failed save the form moved to %s %q", form.starts.clock(), form.starts.zoneName()) - } } -// At the second 01:00 of the night New York falls back — 06:00 UTC — 01:00 is a whole hour on -// the clock, but HEY reads 2026-11-01 01:00 America/New_York as 05:00 UTC, an hour ago. The -// next whole hour it places after now is 02:00 (=> 07:00 UTC), and the hour runs to 03:00 -// (=> 08:00 UTC). -func TestNewEventFormStartsAfterNowAtASecondRepeatedHour(t *testing.T) { - newYork := mustZone(t, "America/New_York") - form := newAccountZoneForm(eventFormCreate, Recording{}, time.Date(2026, 11, 1, 6, 0, 0, 0, time.UTC).In(newYork), "America/New_York") +// A save HEY refuses hands the form back through the view's own failure path, and an identity +// read landing after that leaves it as the reader saved it. +func TestAFailedSaveKeepsWhatTheReaderSaw(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case req.Method == http.MethodPost && req.URL.Path == "/calendar/events.json": + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = io.WriteString(w, `{"error":"Calendar is read-only"}`) + case req.URL.Path == "/identity.json": + _, _ = io.WriteString(w, `{"id":7,"first_week_day":0,"time_zone":"America/Indiana/Indianapolis"}`) + default: + _, _ = io.WriteString(w, `{"starts_at":"2026-08-20T00:00:00Z","ends_at":"2026-08-20T23:59:59Z","kind":"day","recordings":{}}`) + } + })) + t.Cleanup(server.Close) - values := form.values() - if values.StartTime != "02:00" || values.EndTime != "03:00" { - t.Errorf("times = %s → %s, want 02:00 → 03:00", values.StartTime, values.EndTime) - } - wantPlaced(t, "start", values.StartsAt, values.StartTime, values.StartTimeZone, time.Date(2026, 11, 1, 7, 0, 0, 0, time.UTC)) - wantPlaced(t, "end", values.EndsAt, values.EndTime, values.EndTimeZone, time.Date(2026, 11, 1, 8, 0, 0, 0, time.UTC)) -} + v := dayWithEvents(t) + v.vc.ctx = context.Background() + v.vc.sdk = hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "test-token"}, + hey.WithMaxRetries(0)) + v.calendars = eventFormCalendars() + v.Init() + identity := v.fetchIdentity()() -// Lord Howe's clocks go back half an hour, from 02:00 to 01:30, so no whole hour repeats: at the -// second 01:30 (15:00 UTC) the next whole hour is 02:00 (=> 2026-04-04 15:30 UTC), after now. -func TestNewEventFormStartsAfterNowInLordHowesRepeatedHalfHour(t *testing.T) { - lordHowe := mustZone(t, "Australia/Lord_Howe") - form := newAccountZoneForm(eventFormCreate, Recording{}, time.Date(2026, 4, 4, 15, 0, 0, 0, time.UTC).In(lordHowe), "Australia/Lord_Howe") + v.HandleContentKey(keyPress("a")) + v.eventForm.title.SetValue("Design review") + shown := v.eventForm.values() + save := v.HandleContentKey(keyPress("ctrl+s")) + if save == nil { + t.Fatal("ctrl+s did not save") + } + v.Update(save()) + if v.eventForm == nil || v.eventForm.saving || !v.eventForm.isError { + t.Fatalf("the failed save did not hand the form back: %+v", v.eventForm) + } - values := form.values() - if values.StartTime != "02:00" { - t.Errorf("start = %s, want 02:00", values.StartTime) + v.Update(identity) + if got := v.eventForm.values(); got.StartsAt != shown.StartsAt || got.StartTime != shown.StartTime || + got.StartTimeZone != shown.StartTimeZone || got.EndTimeZone != shown.EndTimeZone { + t.Errorf("after a failed save the form shows %s %s %q → %q, want %s %s %q → %q as it was saved", + got.StartsAt, got.StartTime, got.StartTimeZone, got.EndTimeZone, + shown.StartsAt, shown.StartTime, shown.StartTimeZone, shown.EndTimeZone) } - wantPlaced(t, "start", values.StartsAt, values.StartTime, values.StartTimeZone, time.Date(2026, 4, 4, 15, 30, 0, 0, time.UTC)) } // A view pinned to a day that has since become today offers the next whole hour of now, not of @@ -861,3 +867,32 @@ func TestAPinnedViewThatBecameTodayOffersAnHourStillToCome(t *testing.T) { t.Errorf("start = %s, want 2026-08-22 10:00, the day's own hour", got) } } + +// At the second 01:00 of the night New York falls back — 06:00 UTC — 01:00 is a whole hour on +// the clock, but HEY reads 2026-11-01 01:00 America/New_York as 05:00 UTC, an hour ago. The +// next whole hour it places after now is 02:00 (=> 07:00 UTC), and the hour runs to 03:00 +// (=> 08:00 UTC). +func TestNewEventFormStartsAfterNowAtASecondRepeatedHour(t *testing.T) { + newYork := mustZone(t, "America/New_York") + form := newAccountZoneForm(eventFormCreate, Recording{}, time.Date(2026, 11, 1, 6, 0, 0, 0, time.UTC).In(newYork), "America/New_York") + + values := form.values() + if values.StartTime != "02:00" || values.EndTime != "03:00" { + t.Errorf("times = %s → %s, want 02:00 → 03:00", values.StartTime, values.EndTime) + } + wantPlaced(t, "start", values.StartsAt, values.StartTime, values.StartTimeZone, time.Date(2026, 11, 1, 7, 0, 0, 0, time.UTC)) + wantPlaced(t, "end", values.EndsAt, values.EndTime, values.EndTimeZone, time.Date(2026, 11, 1, 8, 0, 0, 0, time.UTC)) +} + +// Lord Howe's clocks go back half an hour, from 02:00 to 01:30, so no whole hour repeats: at the +// second 01:30 (15:00 UTC) the next whole hour is 02:00 (=> 2026-04-04 15:30 UTC), after now. +func TestNewEventFormStartsAfterNowInLordHowesRepeatedHalfHour(t *testing.T) { + lordHowe := mustZone(t, "Australia/Lord_Howe") + form := newAccountZoneForm(eventFormCreate, Recording{}, time.Date(2026, 4, 4, 15, 0, 0, 0, time.UTC).In(lordHowe), "Australia/Lord_Howe") + + values := form.values() + if values.StartTime != "02:00" { + t.Errorf("start = %s, want 02:00", values.StartTime) + } + wantPlaced(t, "start", values.StartsAt, values.StartTime, values.StartTimeZone, time.Date(2026, 4, 4, 15, 30, 0, 0, time.UTC)) +} From f91d03f0584905aae4fc652c6a2727441c365b54 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 16:26:54 -0400 Subject: [PATCH 18/20] Say which saves keep a form as seen, and when Local means no zone docs/tui.md now says that any ctrl+s, refused or failed included, stops a late account zone from changing the form, and that Local is saved without a zone only when both ends are on it: a Local end beside a zoned one is written in that zone, since HEY keeps a zone for both ends or neither. --- docs/tui.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index f1efe817..2c29f962 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -150,10 +150,13 @@ A new event's times are written in your HEY account's time zone, the one HEY's w `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, the form opens on `Local` instead, and a form opened before the read answers takes the zone -when it does, unless you have already edited a date or a time, chosen a zone, or switched -All day. The zone list still offers `Local` -(your machine's clock, saved without a zone) and the zones in your machine's zone database, -or a shortlist of common zones where that database cannot be listed. An edit keeps the +when it does, unless you have already edited a date or a time, chosen a zone, switched +All day, or pressed Ctrl+S — any save, even one that was refused or failed, keeps the form +as you saw it. The zone list still offers `Local` (your machine's clock) and the zones in +your machine's zone database, or a shortlist of common zones where that database cannot be +listed. An event with both ends on `Local` is saved without a zone; a `Local` end beside one +in a named zone is written in that zone, at the same moment, since HEY keeps a zone for both +ends or neither. An edit keeps the event's own zone, and an event saved without one stays on `Local`; an all-day event given a time takes the account's zone. Times are read as HEY reads them, on `Local` too: a time the clocks skip moves on to one that exists. A time you leave showing what it opened with keeps From 138757b1f564c3591b0b73dbbcf8d15bd0540f95 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 16:33:28 -0400 Subject: [PATCH 19/20] Name every end a save would move in the one refusal The refusal named the first end HEY would place away from the moment meant, and a second ctrl+s accepted it as a whole. With both ends imported with seconds, only the start was named, and the second press moved the end too without a word. The refusal now names each end that would move, and says how far, before the second press saves them where HEY reads them. --- internal/tui/event_form.go | 26 +++++++++++++++++--------- internal/tui/event_form_zone_test.go | 22 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/internal/tui/event_form.go b/internal/tui/event_form.go index fa39f628..c01ccf7b 100644 --- a/internal/tui/event_form.go +++ b/internal/tui/event_form.go @@ -769,20 +769,19 @@ func (w wireEnd) placed() (time.Time, bool) { return timezone.Placed(w.date, w.clock, loc) } -// moveProblem is the refusal for an end HEY would place away from the moment meant, saying how -// far and what to do: choose another time, or save again to take HEY's reading of this one. +// moveProblem says why and how far HEY would place an end away from the moment meant; the form +// adds what to do about it (eventForm.moveProblem). func (w wireEnd) moveProblem(label string, placed time.Time) string { - const choice = "Choose another time, or press ctrl+s again to save it there" moved := timezone.MovedBy(w.meant, placed) if !w.meant.Equal(w.meant.Truncate(time.Minute)) { - return fmt.Sprintf("%s — HEY is sent whole minutes, so saving would move it %s. %s", label, moved, choice) + return fmt.Sprintf("%s — HEY is sent whole minutes, so saving would move it %s.", label, moved) } zone := "UTC" if w.zone != "" { zone = terminal.SanitizeLine(w.zone) } - return fmt.Sprintf("%s — HEY reads %s %s %s as another moment, so saving would move it %s. %s", - label, w.date, w.clock, zone, moved, choice) + return fmt.Sprintf("%s — HEY reads %s %s %s as another moment, so saving would move it %s.", + label, w.date, w.clock, zone, moved) } // trixHTML is what the reader typed as the rich text HEY stores an event's notes as: escaped, @@ -896,14 +895,23 @@ func (f *eventForm) moveProblem() string { if f.allDay { return "" } + // Every end that would move is named in the one refusal, because a second ctrl+s accepts + // the refusal as a whole: naming only the start would let the end move unannounced. starts, ends := f.wireEnds() + var moves []string if at, ok := starts.placed(); ok && starts.known && !at.Equal(starts.meant) { - return starts.moveProblem("Starts", at) + moves = append(moves, starts.moveProblem("Starts", at)) } if at, ok := ends.placed(); ok && ends.known && !at.Equal(ends.meant) { - return ends.moveProblem("Ends", at) + moves = append(moves, ends.moveProblem("Ends", at)) } - return "" + switch len(moves) { + case 0: + return "" + case 1: + return moves[0] + " Choose another time, or press ctrl+s again to save it there" + } + return strings.Join(moves, " ") + " Choose other times, or press ctrl+s again to save them there" } // detailProblem is the first thing wrong behind the More row. It is asked separately so that a diff --git a/internal/tui/event_form_zone_test.go b/internal/tui/event_form_zone_test.go index 216c8594..d2316d68 100644 --- a/internal/tui/event_form_zone_test.go +++ b/internal/tui/event_form_zone_test.go @@ -842,6 +842,28 @@ func TestAFailedSaveKeepsWhatTheReaderSaw(t *testing.T) { } } +// When both ends would move, the refusal names both, so the second ctrl+s that accepts it has +// been told about each: an imported event with seconds at either end loses them at both. +func TestARefusalNamesEveryEndThatWouldMove(t *testing.T) { + event := Recording{ + ID: 4823, Title: "Quarterly planning", Type: "Calendar::Event", + StartsAt: time.Date(2026, 8, 20, 13, 30, 30, 0, time.UTC), + EndsAt: time.Date(2026, 8, 20, 14, 45, 15, 0, time.UTC), + } + form := newAccountZoneForm(eventFormEdit, event, time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC), indianapolis) + onMachine(form, time.UTC) + + want := "Starts — HEY is sent whole minutes, so saving would move it 30 seconds earlier. " + + "Ends — HEY is sent whole minutes, so saving would move it 15 seconds earlier. " + + "Choose other times, or press ctrl+s again to save them there" + if _, save := form.handleKey(keyPress("ctrl+s")); save || form.status != want { + t.Fatalf("first ctrl+s saved=%v status=%q, want %q", save, form.status, want) + } + if _, save := form.handleKey(keyPress("ctrl+s")); !save { + t.Errorf("a second ctrl+s did not save both: %q", form.status) + } +} + // A view pinned to a day that has since become today offers the next whole hour of now, not of // the moment it was pinned: `n` to tomorrow at 09:15, left open until 15:10 the next day, and a // new event there opens at 16:00. A day that is not today keeps its own hour. From 306f756e21ad744ca667287a272935a171eb8ec3 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 16:48:49 -0400 Subject: [PATCH 20/20] Take a new event's default date and hour from the same clock The default start took its hour from the account's clock and its date from the machine's. When the two clocks were on different dates it offered a time on neither day: at 17:30 on 14 October in Los Angeles, with a Madrid account, it offered 03:00 on the 14th in Madrid, 18:00 on the 13th in Los Angeles, 23 and a half hours before the form was opened, and the save checks passed it because its ends agreed with what was sent. The start is now the first whole hour on the account's clock that HEY places at or after the moment the form is opened from, date and hour together: 03:00 on the 15th in Madrid there, 18:00 on the 14th in Los Angeles, on the day the reader is looking at. Being the first at or after that moment, it lands on the day in view whenever the day has an hour left, and only past midnight in its last hour. Skipped and repeated hours are passed over as before. The cross-date test asserted the old start and now checks the instant, with the reverse case, another day in view and the night Madrid falls back beside it. --- internal/tui/event_form.go | 54 ++++++++++----------- internal/tui/event_form_zone_test.go | 70 +++++++++++++++++++++++++--- 2 files changed, 91 insertions(+), 33 deletions(-) diff --git a/internal/tui/event_form.go b/internal/tui/event_form.go index c01ccf7b..95282868 100644 --- a/internal/tui/event_form.go +++ b/internal/tui/event_form.go @@ -432,38 +432,38 @@ func zoneMatchesLocal(name string) bool { return named == local } -// newEventStart is where a new event starts when the reader has not said: on the day they are -// looking at, at the next whole hour on the clock the event is written on, so a form opened at -// 09:41 offers 10:00 rather than 09:41. The day is the one on screen even where that clock is -// already on another — the reader chose the day by looking at it. +// newEventStart is where a new event starts when the reader has not said: the first whole hour +// on the clock the event is written on that HEY places at or after on — the moment the form +// is opened from, which is now when the day in view is today and the day in view at the +// clock time the view carries otherwise (calendarView.newEventDay). A form opened at 09:41 +// offers 10:00. // -// The hour is placed as HEY will place it, so an hour the clocks skip opens on the first one -// after it: 01:41 on the morning New York springs forward offers 03:00, not an 02:00 that Go -// would put back at 01:00, before the form was opened. +// The date and the hour are the same clock's. The day in view is drawn on the machine's +// clock, and the account's can be on another date: at 17:30 on 14 October in Los Angeles it +// is 02:30 on the 15th in Madrid, and the hour offered is 03:00 on the 15th there — 18:00 on +// the 14th in Los Angeles, the day the reader is looking at. Taking the date from one clock +// and the hour from the other offered 03:00 on the 14th in Madrid, the evening before. +// +// Because the hour is the first at or after on, it falls on the day in view whenever that day +// has one left; in the day's last hour, or where the two clocks sit half an hour apart, the +// first can be past midnight, and it is offered there rather than an hour already gone. +// +// Each hour is placed as HEY will place it. One the clocks skip is passed over: 01:41 on the +// morning New York springs forward offers 03:00, not an 02:00 HEY would move there anyway. So +// is one HEY places before on: at the second 01:00 of the night New York falls back, HEY reads +// 01:00 as the first, an hour ago, and the form offers 02:00. func newEventStart(on time.Time, zone *time.Location) time.Time { clock := on.In(zone) - hour := clock.Hour() - if clock.Minute() != 0 || clock.Second() != 0 || clock.Nanosecond() != 0 { - hour++ - } - // Written in UTC first so that hour 24 rolls over to the next day and nothing else moves. - wall := time.Date(on.Year(), on.Month(), on.Day(), hour, 0, 0, 0, time.UTC) - start := timezone.WallClock(wall, wall, zone) - - // On the clock's own day the hour offered is one still to come. At the second 01:00 of the - // night New York falls back, 01:00 is a whole hour on the clock, and HEY places it at the - // first, an hour ago; the next one it places after now is 02:00. On a day in view that the - // clock has already left or not yet reached, the hour is the day's, wherever that falls. - if clock.Format("2006-01-02") == on.Format("2006-01-02") { - for range 48 { - if !start.Before(on) { - break - } - wall = wall.Add(time.Hour) - start = timezone.WallClock(wall, wall, zone) + // Written in UTC so the hours can be stepped as figures on the clock, midnight included. + wall := time.Date(clock.Year(), clock.Month(), clock.Day(), clock.Hour(), 0, 0, 0, time.UTC) + for range 72 { + start := timezone.WallClock(wall, wall, zone) + if !start.Before(on) && start.In(zone).Format("2006-01-02 15:04") == wall.Format("2006-01-02 15:04") { + return start.In(zone) } + wall = wall.Add(time.Hour) } - return start.In(zone) + return clock } // indexOfCalendar finds the calendar an event is filed on. The id is the answer where the diff --git a/internal/tui/event_form_zone_test.go b/internal/tui/event_form_zone_test.go index d2316d68..ebd1d9e0 100644 --- a/internal/tui/event_form_zone_test.go +++ b/internal/tui/event_form_zone_test.go @@ -62,17 +62,75 @@ func TestNewEventFormOpensOnTheAccountZone(t *testing.T) { } } -// The day is the one on screen, even where the account's clock has already moved on to the -// next: a reader looking at the 14th late in the evening gets an event on the 14th. +// wantDefaultStart checks the instant a new event is offered: the one HEY places the wire +// values at, no earlier than the moment the form was opened from, and on the day in view as the +// machine's clock draws it. +func wantDefaultStart(t *testing.T, form *eventForm, on, want time.Time) { + t.Helper() + values := form.values() + got, ok := timezone.Placed(values.StartsAt, values.StartTime, mustZone(t, values.StartTimeZone)) + if !ok || !got.Equal(want) { + t.Errorf("start sent as %s %s %s, which HEY places at %s, want %s", + values.StartsAt, values.StartTime, values.StartTimeZone, got.UTC(), want.UTC()) + } + if got.Before(on) { + t.Errorf("start %s is before the form was opened, %s", got.UTC(), on.UTC()) + } + if day := got.In(on.Location()).Format("2006-01-02"); day != on.Format("2006-01-02") { + t.Errorf("start falls on %s on the machine's clock, want the day in view, %s", day, on.Format("2006-01-02")) + } +} + +// The date and the hour are the account clock's together, and the start falls on the day in +// view as the machine draws it. At 17:30 on the 14th in Los Angeles it is 02:30 on the 15th in +// Madrid; the next whole hour there is 03:00 on the 15th (=> 01:00 UTC), 18:00 on the 14th in +// Los Angeles. Taking the date from one clock and the hour from the other offered 03:00 on the +// 14th in Madrid, the evening before. func TestNewEventFormKeepsTheDayInViewOnTheAccountsClock(t *testing.T) { - // 17:30 in Los Angeles on the 14th is 02:30 in Madrid on the 15th. on := time.Date(2026, 10, 14, 17, 30, 0, 0, mustZone(t, "America/Los_Angeles")) form := newAccountZoneForm(eventFormCreate, Recording{}, on, "Europe/Madrid") - values := form.values() - if values.StartsAt != "2026-10-14" || values.StartTime != "03:00" { - t.Errorf("start = %q %q, want 03:00 on the 14th", values.StartsAt, values.StartTime) + if values := form.values(); values.StartsAt != "2026-10-15" || values.StartTime != "03:00" { + t.Errorf("start = %q %q, want 03:00 on the 15th in Madrid", values.StartsAt, values.StartTime) + } + wantDefaultStart(t, form, on, time.Date(2026, 10, 15, 1, 0, 0, 0, time.UTC)) +} + +// The other way round: at 08:15 on the 15th in Madrid (=> 06:15 UTC) it is 23:15 on the 14th +// in Los Angeles, and the next whole hour there is midnight on the 15th (=> 07:00 UTC), 09:00 +// in Madrid on the day in view. +func TestNewEventFormTakesAnAccountClockStillOnTheDayBefore(t *testing.T) { + on := time.Date(2026, 10, 15, 8, 15, 0, 0, mustZone(t, "Europe/Madrid")) + form := newAccountZoneForm(eventFormCreate, Recording{}, on, "America/Los_Angeles") + + if values := form.values(); values.StartsAt != "2026-10-15" || values.StartTime != "00:00" { + t.Errorf("start = %q %q, want midnight on the 15th in Los Angeles", values.StartsAt, values.StartTime) + } + wantDefaultStart(t, form, on, time.Date(2026, 10, 15, 7, 0, 0, 0, time.UTC)) +} + +// A day in view other than today is offered the first whole hour after the clock time the view +// carries, on that day: the 20th in Los Angeles at 17:30 opens at 03:00 on the 21st in Madrid +// (=> 2026-10-21 01:00 UTC), 18:00 on the 20th in Los Angeles. +func TestNewEventFormOnAnotherDayKeepsThatDayOnTheAccountsClock(t *testing.T) { + on := time.Date(2026, 10, 20, 17, 30, 0, 0, mustZone(t, "America/Los_Angeles")) + form := newAccountZoneForm(eventFormCreate, Recording{}, on, "Europe/Madrid") + + wantDefaultStart(t, form, on, time.Date(2026, 10, 21, 1, 0, 0, 0, time.UTC)) +} + +// On the night Madrid falls back, 17:30 on the 24th in Los Angeles (=> 2026-10-25 00:30 UTC) is +// the first 02:30 in Madrid. The whole hour 02:00 repeats there and HEY reads it as the first +// (=> 00:00 UTC), before now; the next is 03:00 (=> 02:00 UTC), 19:00 on the 24th in Los +// Angeles. +func TestNewEventFormOnTheAccountsFallBackNight(t *testing.T) { + on := time.Date(2026, 10, 24, 17, 30, 0, 0, mustZone(t, "America/Los_Angeles")) + form := newAccountZoneForm(eventFormCreate, Recording{}, on, "Europe/Madrid") + + if values := form.values(); values.StartsAt != "2026-10-25" || values.StartTime != "03:00" { + t.Errorf("start = %q %q, want 03:00 on the 25th in Madrid", values.StartsAt, values.StartTime) } + wantDefaultStart(t, form, on, time.Date(2026, 10, 25, 2, 0, 0, 0, time.UTC)) } // Half-hour zones have whole hours of their own: the next one in Kolkata is on the hour there,