Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0800cb6
Move the check for zone names HEY can find into its own package
robzolkos Sep 26, 2026
360a4d9
Write a new event from the TUI in the HEY account's time zone
robzolkos Sep 26, 2026
37fe709
Move HEY's placement of a clock time into the timezone package
robzolkos Sep 26, 2026
d7a5b85
Place the event form's times as HEY will, and wait for the account's …
robzolkos Sep 26, 2026
4f2d340
Say the zone list is the machine's zones or a shortlist
robzolkos Sep 26, 2026
f5250e2
Take only the latest identity read, and end a new event after it starts
robzolkos Sep 26, 2026
124b2fd
Say where HEY places a sent clock time, and whether an instant surviv…
robzolkos Sep 26, 2026
109276d
Read a Local time as HEY does, remember what the reader chose, and sa…
robzolkos Sep 26, 2026
1bd8eb5
Refuse a save HEY would move, and give late account zones to all-day …
robzolkos Sep 26, 2026
d9cb342
Keep a tracked time's untouched ends as they arrived
robzolkos Sep 26, 2026
b5314b2
Find a new event's default end by clock time, not by elapsed step
robzolkos Sep 26, 2026
dfa23f5
Keep an end that shows what it opened with, and let All day answer
robzolkos Sep 26, 2026
7f2b590
Take back the digit the seconds test typed over, whatever the machine…
robzolkos Sep 26, 2026
375c8d7
Offer a new event an hour still to come at the second of a repeated hour
robzolkos Sep 26, 2026
affb94f
Leave a form the reader has saved as they saw it when the account's z…
robzolkos Sep 26, 2026
d462dba
Offer an hour still to come on a pinned view that has become today
robzolkos Sep 26, 2026
09c1b59
Test a failed save through the view's own failure path
robzolkos Sep 26, 2026
f91d03f
Say which saves keep a form as seen, and when Local means no zone
robzolkos Sep 26, 2026
138757b
Name every end a save would move in the one refusal
robzolkos Sep 26, 2026
306f756
Take a new event's default date and hour from the same clock
robzolkos Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,23 @@ 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, 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, 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
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.
50 changes: 14 additions & 36 deletions internal/cmd/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -953,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)
Expand All @@ -964,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 {
Expand All @@ -1006,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 wallClockOn(day, at, z.loc)
at, _ := timezone.Placed(date, clock, loc)
return at
}

// label is the zone as a refusal names it.
Expand Down Expand Up @@ -1081,7 +1059,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,
Expand All @@ -1104,7 +1082,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
Expand Down Expand Up @@ -1141,12 +1119,12 @@ 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)),
endsOn, endTime, terminal.SanitizeLine(loc.String()), timezone.MovedBy(end, placed)),
"pass --end-time to say when it ends")
}
return endsOn, endTime, nil
Expand Down
75 changes: 3 additions & 72 deletions internal/cmd/events_occurrence.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"time"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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'
Expand Down
76 changes: 3 additions & 73 deletions internal/cmd/events_zone.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand Down
Loading
Loading