diff --git a/docs/cli.md b/docs/cli.md index 63a8e8a7..b729206f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -320,7 +320,7 @@ Snippets are named reusable email content, separate from clips saved out of rece `hey box view `, `hey label view ` and `hey collection view ` list the same postings and answer the same formats: `--json`, `--styled`, `--markdown`, `--ids-only`, and `--count`. The data-only formats print the pagination notice and any `next_page` cursor on stderr, so the IDs on stdout stay pipeable. `--json` differs only in what wraps the postings: a box answers with HEY's box payload, a label and a collection with the source and its `total_count`. -Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail. Moving to any of them but Imbox marks the threads seen, and a thread that cannot be replied to is silently not moved to Reply Later. Reply Later is a box rather than a separate flag: moving a Reply Later thread to Imbox removes Reply Later, preserves its seen state, and leaves a seen thread in Previously Seen. It does not return the thread to the box it occupied before Reply Later. A bundle row is refused rather than moved: it stands in for one sender's whole stream in the box they are delivered to, so moving it leaves nothing there for their next email to join and it arrives unbundled instead. Group or ungroup a sender with `hey contact bundle` and `hey contact unbundle`, and read a bundle with `hey bundle view`. Bubble Up has its own commands: `hey bubble up` raises a thread right away with `--now`, on a date with `--on` (HEY resurfaces it at 08:00 UTC that day, or 18:00 UTC when the date is today), or at 08:00 UTC tomorrow, the next Saturday, or next Monday with `--tomorrow`, `--weekend`, and `--next-week`; `hey bubble pop` cancels one, moving the thread to the Imbox and marking it seen rather than returning it to its original box. `hey bubble list` shows both buckets — the threads back in the Imbox after bubbling up and the ones still scheduled, each with when it resurfaces. Trashing a shared thread removes your access instead of deleting it for everyone. Ignoring marks a thread seen and leaves it in its box; while it is ignored `hey unseen` has no effect, and `hey stop-ignoring` resumes notifications but leaves it seen. +Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail. Moving to any of them but Imbox marks the threads seen, and a thread that cannot be replied to is silently not moved to Reply Later. Reply Later is a box rather than a separate flag: moving a Reply Later thread to Imbox removes Reply Later, preserves its seen state, and leaves a seen thread in Previously Seen. It does not return the thread to the box it occupied before Reply Later. A bundle row is refused rather than moved: it stands in for one sender's whole stream in the box they are delivered to, so moving it leaves nothing there for their next email to join and it arrives unbundled instead. Group or ungroup a sender with `hey contact bundle` and `hey contact unbundle`, and read a bundle with `hey bundle view`. Bubble Up has its own commands: `hey bubble up` raises a thread right away with `--now`, on a date with `--on` (HEY resurfaces it at 08:00 UTC that day, or 18:00 UTC when the date is today in UTC), or at 08:00 UTC tomorrow, the next Saturday, or next Monday with `--tomorrow`, `--weekend`, and `--next-week`; `hey bubble pop` cancels one, moving the thread to the Imbox and marking it seen rather than returning it to its original box. `hey bubble list` shows both buckets — the threads back in the Imbox after bubbling up and the ones still scheduled, each with when it resurfaces. Trashing a shared thread removes your access instead of deleting it for everyone. Ignoring marks a thread seen and leaves it in its box; while it is ignored `hey unseen` has no effect, and `hey stop-ignoring` resumes notifications but leaves it seen. ## Watching for changes @@ -401,6 +401,20 @@ and `--all`. Dates want `YYYY-MM-DD`, and an unreadable one or an `--ends-on` be `--starts-on` is a usage error rather than an empty result. Naming only `--starts-on` moves the whole window rather than reading up to the default end. +**Today is your HEY account's today.** Every command that defaults to today — `hey event +day`, `week` and `list`, `hey habit list`, `complete` and `uncomplete`, `hey journal read` +and `write`, and `hey todo add` — works it out in your HEY account's time zone and sends +the date. HEY would read "now" in UTC, and a server's clock is UTC too, so in New York +after 20:00 either would name tomorrow. Resolving today's date reads the identity once for +the account's zone, and naming the date skips that read. A specific `--account` is validated +by a separate identity read before the command runs, so a defaulted command with it reads +the identity twice and a dated command once. If the account has no zone set, or one this +build does not know, a read uses this machine's today and says so on stderr, while a write +is refused and asks you to name the date. A failed read of the account is an error either way, with the +code it failed with; a sign-in failure is reported as one. `hey todo list` and `hey +journal list` read years either side of today, so they use this machine's clock without +asking. + ### Events ```bash @@ -442,8 +456,8 @@ edit` acts on for that day alone. Deleting one day, written out or not, takes it --apply-to current` (see below). A period covers the calendars switched on in HEY, the same set the app draws, so `day` and `week` take no `--calendar` — only `--limit` -and `--all`. With no date they read the account's own today, whatever zone the machine -runs in. +and `--all`. With no date they read the account's today (see above), whatever zone the +machine runs in. An event with no `--start-time` is an all-day event, and a `--start-time` with no `--end-time` runs for an hour — unless `--ends-on` names a later day, when it ends there at diff --git a/internal/cmd/account_zone.go b/internal/cmd/account_zone.go new file mode 100644 index 00000000..1346b74b --- /dev/null +++ b/internal/cmd/account_zone.go @@ -0,0 +1,138 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/terminal" + "github.com/basecamp/hey-cli/internal/timezone" +) + +// clockNow is the clock a default date is read from, a seam for tests. The location it +// answers in is the machine's, which only a read falls back on, and only when the account +// has no zone to tell today in. +var clockNow = time.Now + +// todayUnknown is what a refusal to name today says it could not do. +const todayUnknown = "cannot tell which day today is" + +// accountZone is the HEY account's time zone as the identity serves it. Zone resolution +// reads the identity at most once and only when something needs it: a clock time to place, +// or a today to name. A specific --account is validated by a separate identity read before +// the command runs. +type accountZone struct { + read bool + name string + err error +} + +// timeZone is the zone HEY's web app reads typed times and today in: the one the identity +// serves, which HEY keeps in step with the browser the account last signed in from. +func (a *accountZone) timeZone(ctx context.Context) (string, error) { + if !a.read { + identity, err := rootSDK.Identity().GetIdentity(ctx) + *a = accountZone{read: true, err: err} + if err == nil && identity != nil { + a.name = identity.TimeZone + } + } + return a.name, a.err +} + +// location is the account's zone, loaded. An account with no zone, or with one this build +// cannot load, answers an *unknownAccountZoneError; a failed read answers the read's own error. +func (a *accountZone) location(ctx context.Context) (string, *time.Location, error) { + name, err := a.timeZone(ctx) + if err != nil { + return "", nil, err + } + if name == "" { + return "", nil, &unknownAccountZoneError{reason: "your HEY account has no time zone set"} + } + loc, err := timezone.Load(name) + if err != nil { + return "", nil, &unknownAccountZoneError{ + reason: fmt.Sprintf("your HEY account's time zone %s is not one this build of hey knows", terminal.SanitizeLine(name)), + cause: err, + } + } + return name, loc, nil +} + +// todayToWrite is today in the account's zone, for a command that writes to a day it was +// not given. HEY answers a JSON request in UTC, and the machine's clock is UTC on a server +// and in most sandboxes, so in New York after 20:00 either would write to tomorrow. Without +// the account's zone the write is refused rather than guessed at, and hint names the date +// argument that would do instead. +func (a *accountZone) todayToWrite(ctx context.Context, hint string) (time.Time, error) { + _, loc, err := a.location(ctx) + if err != nil { + return time.Time{}, accountZoneRefusal(err, todayUnknown, hint) + } + return calendarDay(clockNow().In(loc)), nil +} + +// todayToRead is today in the account's zone, for a command that reads a day it was not +// given. An account with no zone is a lasting state rather than a failure, and refusing +// would leave the command unusable without a date, so the read takes the machine's today — +// as HEY's web app takes the browser's — and says so on stderr. A read of the identity that +// fails is refused as a write's is: it is worth a retry, and a guessed day is not. +func (a *accountZone) todayToRead(ctx context.Context, stderr io.Writer, hint string) (time.Time, error) { + _, loc, err := a.location(ctx) + if unknown, ok := errors.AsType[*unknownAccountZoneError](err); ok { + today := calendarDay(clockNow()) + fmt.Fprintf(stderr, "Notice: %s, so today is %s by this machine's clock\n", unknown.reason, today.Format(dateLayout)) + return today, nil + } + if err != nil { + return time.Time{}, accountZoneRefusal(err, todayUnknown, hint) + } + return calendarDay(clockNow().In(loc)), nil +} + +// calendarDay is the day an instant falls on where it was read, as a date with no zone left +// in it, so that adding days to it never meets a clock change. +func calendarDay(at time.Time) time.Time { + return time.Date(at.Year(), at.Month(), at.Day(), 0, 0, 0, 0, time.UTC) +} + +// unknownAccountZoneError is an account whose zone cannot be used: none is set, or this build +// does not know the one that is. +type unknownAccountZoneError struct { + reason string + cause error +} + +func (e *unknownAccountZoneError) Error() string { return e.reason } +func (e *unknownAccountZoneError) Unwrap() error { return e.cause } + +// accountZoneRefusal says why there is no zone to work in, what it was needed for, and what +// to pass instead. An account without a usable zone is a usage error. A failed read keeps +// what it failed with — network, rate limit, a server error — so a script can tell a retry +// from a mistake. A login HEY no longer accepts is an auth failure and nothing else: the +// hint would only move the refusal to the next request. +func accountZoneRefusal(err error, purpose, hint string) error { + if unknown, ok := errors.AsType[*unknownAccountZoneError](err); ok { + return &apierr.Error{ + Code: apierr.CodeUsage, + Message: purpose + ": " + unknown.reason, + Hint: hint, + Cause: unknown.cause, + } + } + + readErr := *apierr.AsError(apierr.FromSDK(err)) + if readErr.Code == apierr.CodeAuth { + return &readErr + } + readErr.Message = purpose + ": your HEY account's time zone could not be read: " + readErr.Message + readErr.Hint = hint + if readErr.Cause == nil { + readErr.Cause = err + } + return &readErr +} diff --git a/internal/cmd/account_zone_test.go b/internal/cmd/account_zone_test.go new file mode 100644 index 00000000..6eda578c --- /dev/null +++ b/internal/cmd/account_zone_test.go @@ -0,0 +1,353 @@ +package cmd + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/basecamp/hey-cli/internal/apierr" +) + +// newYorkIdentity is the identity of an account whose time zone is New York. +const newYorkIdentity = `{"id":1,"name":"Jason Fried","time_zone":"America/New_York"}` + +// todayFixture is what todayServer answers the identity read with: a zone, a status, or a +// connection that drops. +type todayFixture struct { + accountZone string + identityStatus int + dropIdentity bool +} + +// todayRequests counts the identity reads and keeps every other request a command made, in +// a form that names the day it was sent for. +type todayRequests struct { + identity atomic.Int32 + + mu sync.Mutex + sent []string +} + +func (r *todayRequests) record(request string) { + r.mu.Lock() + defer r.mu.Unlock() + r.sent = append(r.sent, request) +} + +func (r *todayRequests) all() string { + r.mu.Lock() + defer r.mu.Unlock() + return strings.Join(r.sent, "\n") +} + +// todayServer answers every read and write a command that defaults to today makes: the +// identity, a day, a week, the calendars and their recordings, a journal entry, a habit's +// completion and a new to-do. +func todayServer(t *testing.T, fixture todayFixture) (http.Handler, *todayRequests) { + t.Helper() + requests := &todayRequests{} + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := strings.TrimSuffix(r.URL.Path, ".json") + switch { + case path == "/identity": + requests.identity.Add(1) + switch { + case fixture.dropIdentity: + if conn, _, err := w.(http.Hijacker).Hijack(); err == nil { + _ = conn.Close() + } + case fixture.identityStatus != 0: + http.Error(w, `{"error":"identity unavailable"}`, fixture.identityStatus) + default: + zone := "null" + if fixture.accountZone != "" { + zone = `"` + fixture.accountZone + `"` + } + _, _ = io.WriteString(w, `{"id":1,"name":"Jason Fried","time_zone":`+zone+`,`+ + `"accounts":[{"id":2,"name":"Work","purpose":"work","status":"active"}]}`) + } + case path == "/calendars": + requests.record("GET /calendars") + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Work","owned":true,"kind":"normal"}}]}`) + case path == "/calendars/9/recordings": + requests.record("GET /calendars/9/recordings starts_on=" + r.URL.Query().Get("starts_on") + " ends_on=" + r.URL.Query().Get("ends_on")) + _, _ = io.WriteString(w, `{"Calendar::Event":[]}`) + case strings.HasPrefix(path, "/calendar/days/") && strings.Count(path, "/") == 3, + strings.HasPrefix(path, "/calendar/weeks/"): + requests.record(r.Method + " " + path) + _, _ = io.WriteString(w, `{"kind":"day","recordings":{}}`) + case path == "/calendar/todos": + var body struct { + Todo struct { + StartsAt string `json:"starts_at"` + } `json:"calendar_todo"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + requests.record("POST /calendar/todos starts_at=" + body.Todo.StartsAt) + w.WriteHeader(http.StatusNoContent) + default: + requests.record(r.Method + " " + path) + w.WriteHeader(http.StatusNoContent) + } + }), requests +} + +// atInstantOn pins the clock to an instant as a machine in zone reads it. +func atInstantOn(t *testing.T, instant, zone string) { + t.Helper() + at, err := time.Parse(time.RFC3339, instant) + if err != nil { + t.Fatalf("instant %q: %v", instant, err) + } + loc, err := time.LoadLocation(zone) + if err != nil { + t.Fatalf("zone %q: %v", zone, err) + } + previous := clockNow + clockNow = func() time.Time { return at.In(loc) } + t.Cleanup(func() { clockNow = previous }) +} + +// todayCommand is a command that defaults to today, and the request it names the day in. +type todayCommand struct { + name string + args []string + sends func(day string) string + write bool +} + +var todayCommands = []todayCommand{ + {name: "event day", args: []string{"event", "day"}, sends: func(day string) string { return "GET /calendar/days/" + day }}, + {name: "event week", args: []string{"event", "week"}, sends: func(day string) string { return "GET /calendar/weeks/" + day }}, + {name: "event list", args: []string{"event", "list"}, sends: func(day string) string { + start, _ := time.Parse(dateLayout, day) + return "GET /calendars/9/recordings starts_on=" + day + " ends_on=" + start.AddDate(0, 0, 30).Format(dateLayout) + }}, + {name: "habit list", args: []string{"habit", "list"}, sends: func(day string) string { return "GET /calendar/weeks/" + day }}, + {name: "journal read", args: []string{"journal", "read"}, sends: func(day string) string { return "GET /calendar/days/" + day + "/journal_entry" }}, + {name: "habit complete", args: []string{"habit", "complete", "42"}, write: true, sends: func(day string) string { return "POST /calendar/days/" + day + "/habits/42/completions" }}, + {name: "habit uncomplete", args: []string{"habit", "uncomplete", "42"}, write: true, sends: func(day string) string { return "DELETE /calendar/days/" + day + "/habits/42/completions" }}, + {name: "journal write", args: []string{"journal", "write", "Shipped the pagination fix."}, write: true, sends: func(day string) string { return "PATCH /calendar/days/" + day + "/journal_entry" }}, + {name: "todo add", args: []string{"todo", "add", "Buy groceries"}, write: true, sends: func(day string) string { return "POST /calendar/todos starts_at=" + day }}, +} + +// withDate is the command with the day named, the way each command takes one. +func (c todayCommand) withDate(day string) []string { + args := append([]string{}, c.args...) + switch c.name { + case "event day", "event week", "journal read": + return append(args, day) + case "event list": + return append(args, "--starts-on", day) + case "journal write": + return []string{"journal", "write", day, args[2]} + default: + return append(args, "--date", day) + } +} + +// The reported case: at 02:00 UTC on the 15th it is still the evening of the 14th in New +// York. HEY answers a JSON request in UTC, and a server's clock is UTC too, so asking HEY for +// "now" or reading the machine's date both named the 15th. Every command that defaults to +// today names the account's day, once, whatever the machine's zone. +func TestTodayIsTheAccountsToday(t *testing.T) { + for _, command := range todayCommands { + t.Run(command.name, func(t *testing.T) { + atInstantOn(t, "2026-10-15T02:00:00Z", "UTC") + handler, requests := todayServer(t, todayFixture{accountZone: "America/New_York"}) + if _, err := runJSONCommand(t, handler, command.args...); err != nil { + t.Fatalf("execute %s: %v", command.name, err) + } + if sent := requests.all(); !strings.Contains(sent, command.sends("2026-10-14")) { + t.Errorf("requests =\n%s\nwant %q", sent, command.sends("2026-10-14")) + } + if got := requests.identity.Load(); got != 1 { + t.Errorf("identity reads = %d, want 1", got) + } + }) + } +} + +// A day that is named is the day sent, and the account is not asked for its zone. +func TestANamedDayReadsNoAccountZone(t *testing.T) { + for _, command := range todayCommands { + t.Run(command.name, func(t *testing.T) { + atInstantOn(t, "2026-10-15T02:00:00Z", "UTC") + handler, requests := todayServer(t, todayFixture{identityStatus: http.StatusInternalServerError}) + if _, err := runJSONCommand(t, handler, command.withDate("2026-03-15")...); err != nil { + t.Fatalf("execute %s: %v", command.name, err) + } + if sent := requests.all(); !strings.Contains(sent, command.sends("2026-03-15")) { + t.Errorf("requests =\n%s\nwant %q", sent, command.sends("2026-03-15")) + } + if got := requests.identity.Load(); got != 0 { + t.Errorf("identity reads = %d, want none", got) + } + }) + } +} + +// A specific --account is validated by the SDK with an identity read before the command +// runs. Resolving an omitted date makes its own read for the zone; naming the date skips +// that read, but not account validation. +func TestAccountSelectionReadsIdentitySeparatelyFromToday(t *testing.T) { + for _, tt := range []struct { + name string + args []string + wantDay string + identityReads int32 + }{ + {name: "default date", args: []string{"--account", "2", "event", "day"}, wantDay: "2026-10-14", identityReads: 2}, + {name: "named date", args: []string{"--account", "2", "event", "day", "2026-03-15"}, wantDay: "2026-03-15", identityReads: 1}, + } { + t.Run(tt.name, func(t *testing.T) { + atInstantOn(t, "2026-10-15T02:00:00Z", "UTC") + handler, requests := todayServer(t, todayFixture{accountZone: "America/New_York"}) + if _, err := runJSONCommand(t, handler, tt.args...); err != nil { + t.Fatalf("execute event day: %v", err) + } + if sent := requests.all(); !strings.Contains(sent, "GET /calendar/days/"+tt.wantDay) { + t.Errorf("requests =\n%s\nwant the day %s", sent, tt.wantDay) + } + if got := requests.identity.Load(); got != tt.identityReads { + t.Errorf("identity reads = %d, want %d", got, tt.identityReads) + } + }) + } +} + +// An account with no zone to read today in is a lasting state, so a read takes the +// machine's today rather than refusing every time, and says so on stderr. At 12:00 UTC it is +// already the 16th on Kiritimati — neither UTC's day nor any the account could name. +func TestAReadWithoutAnAccountZoneTakesTheMachinesToday(t *testing.T) { + for _, fixture := range []struct { + name string + fixture todayFixture + reason string + }{ + {name: "account names none", fixture: todayFixture{}, reason: "your HEY account has no time zone set"}, + {name: "zone this build does not know", fixture: todayFixture{accountZone: "Mars/Olympus_Mons"}, reason: "your HEY account's time zone Mars/Olympus_Mons is not one this build of hey knows"}, + } { + for _, command := range todayCommands { + if command.write { + continue + } + t.Run(fixture.name+"/"+command.name, func(t *testing.T) { + atInstantOn(t, "2026-10-15T12:00:00Z", "Pacific/Kiritimati") + handler, requests := todayServer(t, fixture.fixture) + stdout, stderr, err := runFormattedCommandWithStderr(t, handler, []string{"--json"}, command.args...) + if err != nil { + t.Fatalf("execute %s: %v", command.name, err) + } + if sent := requests.all(); !strings.Contains(sent, command.sends("2026-10-16")) { + t.Errorf("requests =\n%s\nwant %q", sent, command.sends("2026-10-16")) + } + want := "Notice: " + fixture.reason + ", so today is 2026-10-16 by this machine's clock" + if !strings.Contains(stderr, want) { + t.Errorf("stderr = %q, want %q", stderr, want) + } + if strings.Contains(stdout, "Notice:") { + t.Errorf("stdout = %q, want the notice on stderr alone", stdout) + } + }) + } + } +} + +// Writing to a day nobody named is not a guess to make: without the account's zone a write +// is refused, says how to name the day, and writes nothing. +func TestAWriteWithoutAnAccountZoneIsRefused(t *testing.T) { + for _, fixture := range []struct { + name string + fixture todayFixture + reason string + }{ + {name: "account names none", fixture: todayFixture{}, reason: "your HEY account has no time zone set"}, + {name: "zone this build does not know", fixture: todayFixture{accountZone: "Mars/Olympus_Mons"}, reason: "your HEY account's time zone Mars/Olympus_Mons is not one this build of hey knows"}, + } { + for _, command := range todayCommands { + if !command.write { + continue + } + t.Run(fixture.name+"/"+command.name, func(t *testing.T) { + atInstantOn(t, "2026-10-15T02:00:00Z", "UTC") + handler, requests := todayServer(t, fixture.fixture) + _, err := runJSONCommand(t, handler, command.args...) + cliErr, ok := errors.AsType[*apierr.Error](err) + if !ok || cliErr.Code != apierr.CodeUsage { + t.Fatalf("error = %v, want a usage error", err) + } + if want := "cannot tell which day today is: " + fixture.reason; cliErr.Message != want { + t.Errorf("message = %q, want %q", cliErr.Message, want) + } + if !strings.Contains(cliErr.Hint, "2026-10-14") { + t.Errorf("hint = %q, want it to show how to name the day", cliErr.Hint) + } + if sent := requests.all(); sent != "" { + t.Errorf("requests =\n%s\nwant none", sent) + } + }) + } + } +} + +// A read of the identity that fails is refused by reads and writes alike — a retry answers +// it, and a guessed day does not — and keeps what it failed with, so a script can tell a +// retry from a mistake. A login HEY no longer accepts is an auth failure and nothing else. +// Nothing else is requested. +func TestAFailedAccountReadKeepsItsCode(t *testing.T) { + for _, tt := range []struct { + name string + fixture todayFixture + code string + status int + // only names the one command a failure is tried on: the SDK retries it after a + // backoff first, which is seconds a command. + only string + }{ + {name: "server error", fixture: todayFixture{identityStatus: http.StatusInternalServerError}, code: apierr.CodeAPI, status: http.StatusInternalServerError}, + {name: "rate limited", fixture: todayFixture{identityStatus: http.StatusTooManyRequests}, code: apierr.CodeRateLimit, status: http.StatusTooManyRequests, only: "event day"}, + {name: "unreachable", fixture: todayFixture{dropIdentity: true}, code: apierr.CodeAPI, only: "todo add"}, + {name: "signed out", fixture: todayFixture{identityStatus: http.StatusUnauthorized}, code: apierr.CodeAuth, status: http.StatusUnauthorized}, + } { + for _, command := range todayCommands { + if tt.only != "" && command.name != tt.only { + continue + } + t.Run(tt.name+"/"+command.name, func(t *testing.T) { + atInstantOn(t, "2026-10-15T02:00:00Z", "UTC") + handler, requests := todayServer(t, tt.fixture) + _, err := runJSONCommand(t, handler, command.args...) + cliErr, ok := errors.AsType[*apierr.Error](err) + if !ok || cliErr.Code != tt.code { + t.Fatalf("error = %#v, want code %s", err, tt.code) + } + if tt.code == apierr.CodeAuth { + if strings.Contains(cliErr.Message, "today") || strings.Contains(cliErr.Hint, "2026-10-14") { + t.Errorf("error = %q (hint %q), want the auth failure as it is", cliErr.Message, cliErr.Hint) + } + } else { + if tt.status != 0 && cliErr.HTTPStatus != tt.status { + t.Errorf("status = %d, want %d", cliErr.HTTPStatus, tt.status) + } + if !strings.HasPrefix(cliErr.Message, "cannot tell which day today is: your HEY account's time zone could not be read: ") { + t.Errorf("message = %q, want it to say the zone could not be read", cliErr.Message) + } + if !strings.Contains(cliErr.Hint, "2026-10-14") { + t.Errorf("hint = %q, want it to show how to name the day", cliErr.Hint) + } + } + if sent := requests.all(); sent != "" { + t.Errorf("requests =\n%s\nwant none", sent) + } + }) + } + } +} diff --git a/internal/cmd/bubble.go b/internal/cmd/bubble.go index 75c60f20..1f48b9c9 100644 --- a/internal/cmd/bubble.go +++ b/internal/cmd/bubble.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "time" "github.com/spf13/cobra" @@ -44,13 +43,13 @@ func newBubbleUpCommand() *bubbleUpCommand { bubbleUpCommand.cmd = &cobra.Command{ Use: "up ... (--now | --on | --tomorrow | --weekend | --next-week)", Short: "Bubble email threads up", - Long: "Bubble one or more email threads up to the top of the Imbox: right away with --now, at 08:00 UTC on a date with --on, or at 08:00 UTC tomorrow, the next Saturday, or next Monday with the named flags. Today's date under --on schedules HEY's Later today slot instead — 18:00 UTC — since this morning has already passed. HEY schedules in UTC, whatever the local zone.", + Long: "Bubble one or more email threads up to the top of the Imbox: right away with --now, at 08:00 UTC on a date with --on, or at 08:00 UTC tomorrow, the next Saturday, or next Monday with the named flags. Today's date in UTC under --on schedules HEY's Later today slot instead — 18:00 UTC — since this morning has already passed. HEY schedules in UTC, whatever the local zone or the account's.", Example: ` hey bubble up 12345 --now hey bubble up 12345 67890 --now hey bubble up 12345 --on 2026-09-04 hey bubble up 12345 --weekend`, Annotations: map[string]string{ - "agent_notes": "Accepts one or more box item IDs from hey box view output. Exactly one of --now, --on, --tomorrow, --weekend and --next-week is required. --on takes a YYYY-MM-DD date; HEY bubbles the threads up at 08:00 UTC that day, or at 18:00 UTC when the date is today by the local clock. --tomorrow, --weekend and --next-week land at 08:00 UTC tomorrow, the next Saturday, and next Monday.", + "agent_notes": "Accepts one or more box item IDs from hey box view output. Exactly one of --now, --on, --tomorrow, --weekend and --next-week is required. --on takes a YYYY-MM-DD date; HEY bubbles the threads up at 08:00 UTC that day, or at 18:00 UTC when the date is today in UTC, HEY's clock for bubbling up, not the local one. --tomorrow, --weekend and --next-week land at 08:00 UTC tomorrow, the next Saturday, and next Monday.", }, RunE: bubbleUpCommand.run, Args: usageMinOneArg(), @@ -99,7 +98,9 @@ func (c *bubbleUpCommand) run(cmd *cobra.Command, args []string) error { return err } - if on.Format(dateLayout) == time.Now().Format(dateLayout) { + // HEY lays every slot out in UTC, "later today" included, so the day that has already had + // its morning is UTC's today — not the machine's, and not the account's. + if on.Format(dateLayout) == clockNow().UTC().Format(dateLayout) { return c.scheduleFor(cmd, hey.BubbleUpLaterToday, "this evening", ids) } diff --git a/internal/cmd/bubble_test.go b/internal/cmd/bubble_test.go index d53d8bcd..56ca7a9b 100644 --- a/internal/cmd/bubble_test.go +++ b/internal/cmd/bubble_test.go @@ -10,7 +10,6 @@ import ( "strconv" "strings" "testing" - "time" "github.com/basecamp/hey-cli/internal/apierr" "github.com/basecamp/hey-cli/internal/output" @@ -123,8 +122,9 @@ func runBubbleOutput(t *testing.T, server *httptest.Server, args ...string) (str } func TestBubbleUpAndPop(t *testing.T) { - today := time.Now().Format("2006-01-02") - later := time.Now().AddDate(0, 0, 7).Format("2006-01-02") + // 21:00 on the 14th in New York is already 01:00 on the 15th in UTC, which is the clock + // HEY lays its bubble-up slots out on: the 15th is today, and the 14th is not. + const today, later, newYorksToday = "2026-10-15", "2026-10-22", "2026-10-14" tests := []struct { name string args []string @@ -141,6 +141,7 @@ func TestBubbleUpAndPop(t *testing.T) { {"up multiple on a date", []string{"up", "12345", "67890", "--on", later}, http.MethodPost, "/postings/bubble_up.json", []int64{12345, 67890}, "custom", later, "2 threads will bubble up on " + later}, {"up one on today", []string{"up", "12345", "--on", today}, http.MethodPost, "/postings/bubble_up.json", []int64{12345}, "today", "", "1 thread will bubble up this evening"}, {"up multiple on today", []string{"up", "12345", "67890", "--on", today}, http.MethodPost, "/postings/bubble_up.json", []int64{12345, 67890}, "today", "", "2 threads will bubble up this evening"}, + {"up on the local today", []string{"up", "12345", "--on", newYorksToday}, http.MethodPost, "/postings/bubble_up.json", []int64{12345}, "custom", newYorksToday, "1 thread will bubble up on " + newYorksToday}, {"up tomorrow", []string{"up", "12345", "--tomorrow"}, http.MethodPost, "/postings/bubble_up.json", []int64{12345}, "tomorrow", "", "1 thread will bubble up tomorrow morning"}, {"up weekend", []string{"up", "12345", "67890", "--weekend"}, http.MethodPost, "/postings/bubble_up.json", []int64{12345, 67890}, "weekend", "", "2 threads will bubble up Saturday morning"}, {"up next week", []string{"up", "12345", "--next-week"}, http.MethodPost, "/postings/bubble_up.json", []int64{12345}, "next_week", "", "1 thread will bubble up Monday morning"}, @@ -150,6 +151,7 @@ func TestBubbleUpAndPop(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + atInstantOn(t, "2026-10-15T01:00:00Z", "America/New_York") server, recorded := bubbleServer(t) resp, err := runBubble(t, server, tt.args...) if err != nil { diff --git a/internal/cmd/calendar_commands_test.go b/internal/cmd/calendar_commands_test.go index 3e14508a..0eb9324a 100644 --- a/internal/cmd/calendar_commands_test.go +++ b/internal/cmd/calendar_commands_test.go @@ -146,6 +146,8 @@ func TestEventsListReadsEveryCalendar(t *testing.T) { response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { + case "/identity.json": + _, _ = io.WriteString(w, newYorkIdentity) case "/calendars.json": _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":7,"name":"Personal","personal":true}},{"calendar":{"id":9,"name":"Work","owned":true}}]}`) case "/calendars/7/recordings.json": diff --git a/internal/cmd/events.go b/internal/cmd/events.go index fdb401e4..90a09087 100644 --- a/internal/cmd/events.go +++ b/internal/cmd/events.go @@ -31,7 +31,7 @@ func newEventsCommand() *eventsCommand { Use: "event", Short: "Read and manage calendar events", Annotations: map[string]string{ - "agent_notes": "Subcommands: list, day, week, add, edit, delete. \"What's on the schedule today?\" is answered by day, not list: day and week read the span as HEY draws it, with a repeating event expanded into the occurrences inside it, over the calendars switched on in HEY. list reads what calendars hold — every calendar unless --calendar names one — and a repeating event is one row, its series, on the day the series began. An edit is not a patch on HEY's side: it resends the notes, location, link, attached email, reminders and time zones the event already carries, so notes lose their formatting and a countdown is removed unless --countdown names one again. edit changes a whole series; one day of it is edit --occurrence --apply-to current|future, which keeps the countdown and refuses to flatten notes unless --allow-plain-notes or --notes is given. --apply-to future starts a new series and requires --repeat with its complete schedule; a preset alone means forever and custom copies an existing opaque schedule (a count-based rule can restart its full count). A virtual day of an opaque custom schedule is read from HEY's Day view and refused if that view no longer serves it. A realized custom day cannot be split safely; use a virtual occurrence, edit that day alone, or edit the whole series. A realized preset occurrence moved away from its series time must be moved back with --apply-to current before a future split. Read the day again afterward for the new series id. delete deletes the whole series; one day of it is delete --occurrence --apply-to current|future, whether or not HEY has written that day out. delete refuses a written-out day's own id, because HEY would draw the day again from the series. add and edit read clock times, and today when --starts-on is left out, in the HEY account's time zone unless --time-zone names one, and refuse when they need it and the account has none; an edit keeps a zoned event's zone and leaves a zoneless event zoneless.", + "agent_notes": "Subcommands: list, day, week, add, edit, delete. \"What's on the schedule today?\" is answered by day, not list: day and week read the span as HEY draws it, with a repeating event expanded into the occurrences inside it, over the calendars switched on in HEY; without a date they, and list's window, start from today in the HEY account's time zone. list reads what calendars hold — every calendar unless --calendar names one — and a repeating event is one row, its series, on the day the series began. An edit is not a patch on HEY's side: it resends the notes, location, link, attached email, reminders and time zones the event already carries, so notes lose their formatting and a countdown is removed unless --countdown names one again. edit changes a whole series; one day of it is edit --occurrence --apply-to current|future, which keeps the countdown and refuses to flatten notes unless --allow-plain-notes or --notes is given. --apply-to future starts a new series and requires --repeat with its complete schedule; a preset alone means forever and custom copies an existing opaque schedule (a count-based rule can restart its full count). A virtual day of an opaque custom schedule is read from HEY's Day view and refused if that view no longer serves it. A realized custom day cannot be split safely; use a virtual occurrence, edit that day alone, or edit the whole series. A realized preset occurrence moved away from its series time must be moved back with --apply-to current before a future split. Read the day again afterward for the new series id. delete deletes the whole series; one day of it is delete --occurrence --apply-to current|future, whether or not HEY has written that day out. delete refuses a written-out day's own id, because HEY would draw the day again from the series. add and edit read clock times, and today when --starts-on is left out, in the HEY account's time zone unless --time-zone names one, and refuse when they need it and the account has none; an edit keeps a zoned event's zone and leaves a zoneless event zoneless.", }, } @@ -63,7 +63,11 @@ func newEventsListCommand() *eventsListCommand { A repeating event is stored once, so it lists once, as its series, on the day the series began. For the events of a day or a week as HEY draws them — occurrences of a repeating -series expanded into the days they fall on — read 'hey event day' or 'hey event week'.`, +series expanded into the days they fall on — read 'hey event day' or 'hey event week'. + +Without --starts-on the window is the 30 days from today, today in your HEY account's time +zone, whatever this machine's is. An account with no time zone set reads this machine's +today, and says so on stderr.`, Example: ` hey event list hey event list --starts-on 2026-01-01 --ends-on 2026-01-31 hey event list --calendar 123 --limit 5 --json`, @@ -81,6 +85,13 @@ func (c *eventsListCommand) run(cmd *cobra.Command, args []string) error { return err } + // The window starts at today, so today is the account's: in New York after 20:00 the + // machine's clock on a server, like HEY's own, is already on tomorrow. + var account accountZone + c.filter.today = func(ctx context.Context) (time.Time, error) { + return account.todayToRead(ctx, cmd.ErrOrStderr(), "name the start, for example --starts-on 2026-10-14") + } + ctx := cmd.Context() window, err := c.filter.resolve(ctx) if err != nil { @@ -478,7 +489,7 @@ func eventSearchWindow(ctx context.Context, calendar int64, on string) (recordin calendar: calendar, startsOn: on, endsOn: endsOn, - defaultWindow: func(now time.Time) (time.Time, time.Time) { return now.AddDate(-1, 0, 0), now.AddDate(1, 0, 0) }, + defaultWindow: func(today time.Time) (time.Time, time.Time) { return today.AddDate(-1, 0, 0), today.AddDate(1, 0, 0) }, defaultCalendars: allCalendarIDs, } return filter.resolve(ctx) @@ -764,7 +775,7 @@ func (f *eventFields) newSchedule(ctx context.Context) (eventSchedule, error) { if name, loc, err = f.writeZone(ctx); err != nil { return eventSchedule{}, err } - today = eventNow().In(loc) + today = clockNow().In(loc) if !allDay { zone = name } diff --git a/internal/cmd/events_occurrence.go b/internal/cmd/events_occurrence.go index 032217d0..32650556 100644 --- a/internal/cmd/events_occurrence.go +++ b/internal/cmd/events_occurrence.go @@ -671,7 +671,7 @@ func countdownFromRecording(countdown, event generated.Recording, additionalZone func (c *eventsEditCommand) countdownFromRecording(ctx context.Context, countdown, event generated.Recording) (hey.CountdownParams, error) { params, unreadable := countdownFromRecording(countdown, event) - zone, err := c.fields.accountTimeZone(ctx) + zone, err := c.fields.account.timeZone(ctx) if err != nil { return hey.CountdownParams{}, apierr.FromSDK(err) } diff --git a/internal/cmd/events_period.go b/internal/cmd/events_period.go index f8baf26b..afd1d103 100644 --- a/internal/cmd/events_period.go +++ b/internal/cmd/events_period.go @@ -31,8 +31,12 @@ type eventsPeriodCommand struct { read func(ctx context.Context, date string) (*generated.CalendarPeriod, error) // describe names the span read, in words that follow "No events" and sit inside the - // summary's parentheses: "on 2026-09-02", "in the week of 2026-09-02". - describe func(date string) string + // summary's parentheses: "on 2026-09-02", "in the week of 2026-09-02". today says the + // date was not given. + describe func(date string, today bool) string + + // todayHint is how a refusal to name today says to name the date instead. + todayHint string } func newEventsDayCommand() *eventsPeriodCommand { @@ -40,12 +44,13 @@ func newEventsDayCommand() *eventsPeriodCommand { read: func(ctx context.Context, date string) (*generated.CalendarPeriod, error) { return sdk.CalendarPeriods().Day(ctx, date) }, - describe: func(date string) string { - if date == periodNow { - return "today" + describe: func(date string, today bool) string { + if today { + return "today, " + date } return "on " + date }, + todayHint: "name the day, for example hey event day 2026-10-14", } eventsDayCommand.cmd = &cobra.Command{ Use: "day [date]", @@ -57,6 +62,9 @@ standup on the day the series began and on no other. A day is HEY's own expansio event that falls on it, occurrences of a repeating series included, and nothing from outside it. +Without a date the day is today in your HEY account's time zone, whatever this machine's +is. An account with no time zone set reads this machine's today, and says so on stderr. + The day covers the calendars switched on in HEY, the same set the app draws, so there is no --calendar to narrow it. A virtual occurrence carries the series in id and parent_id. A day HEY has written out on its own — after an edit of that day alone, or a reminder — @@ -80,12 +88,13 @@ func newEventsWeekCommand() *eventsPeriodCommand { read: func(ctx context.Context, date string) (*generated.CalendarPeriod, error) { return sdk.CalendarPeriods().Week(ctx, date) }, - describe: func(date string) string { - if date == periodNow { - return "this week" + describe: func(date string, today bool) string { + if today { + return "this week, the week of " + date } return "in the week of " + date }, + todayHint: "name a day in the week, for example hey event week 2026-10-14", } eventsWeekCommand.cmd = &cobra.Command{ Use: "week [date]", @@ -93,6 +102,10 @@ func newEventsWeekCommand() *eventsPeriodCommand { Long: `List the events of the week a date falls in, as HEY's Week View draws it: every event inside the week, occurrences of a repeating series included. Any day names its week. +Without a date the week is the one today falls in, today in your HEY account's time zone, +whatever this machine's is. An account with no time zone set reads this machine's today, +and says so on stderr. + The week covers the calendars switched on in HEY, the same set the app draws, so there is no --calendar to narrow it. A virtual occurrence carries the series in id and parent_id. A day HEY has written out on its own — after an edit of that day alone, or a reminder — @@ -121,17 +134,12 @@ func (c *eventsPeriodCommand) run(cmd *cobra.Command, args []string) error { return err } - // With no date the read asks for "now" and HEY resolves today in the account's own - // time zone, so a host in another zone does not fetch yesterday's schedule at midnight. - date := periodNow - if len(args) > 0 { - if _, err := parseDateArg("date", args[0]); err != nil { - return err - } - date = args[0] + ctx := cmd.Context() + date, described, err := c.date(ctx, cmd, args) + if err != nil { + return err } - ctx := cmd.Context() period, err := c.read(ctx, date) if err != nil { return apierr.FromSDK(err) @@ -150,12 +158,28 @@ func (c *eventsPeriodCommand) run(cmd *cobra.Command, args []string) error { } notice := output.TruncationNotice(len(rows), total) - return writeEventRows(cmd, rows, c.describe(date), notice) + return writeEventRows(cmd, rows, described, notice) } -// periodNow is the date the period reads accept for today: HEY resolves it in the -// account's own time zone, which the CLI process's clock cannot. -const periodNow = "now" +// date is the day the period is read around, and the words that name it. With no date given +// it is today in the account's zone, sent as a date: HEY resolves "now" in UTC on a JSON +// request, which in New York after 20:00 is tomorrow. +func (c *eventsPeriodCommand) date(ctx context.Context, cmd *cobra.Command, args []string) (string, string, error) { + if len(args) > 0 { + if _, err := parseDateArg("date", args[0]); err != nil { + return "", "", err + } + return args[0], c.describe(args[0], false), nil + } + + var account accountZone + today, err := account.todayToRead(ctx, cmd.ErrOrStderr(), c.todayHint) + if err != nil { + return "", "", err + } + date := today.Format(dateLayout) + return date, c.describe(date, true), nil +} // eventRow is the event shape the CLI publishes. A virtual occurrence's id names its // series; a realized day's id and RecordingID both name its own event. diff --git a/internal/cmd/events_period_test.go b/internal/cmd/events_period_test.go index 46e7de7d..8cfe1768 100644 --- a/internal/cmd/events_period_test.go +++ b/internal/cmd/events_period_test.go @@ -230,26 +230,6 @@ func TestEventBoundaryDrawsTheReadersClock(t *testing.T) { } } -// With no date the read asks HEY for "now", which the server resolves in the account's own -// time zone — the CLI process's clock could be a day off either way around midnight. -func TestEventsDayDefaultsToNow(t *testing.T) { - response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet || r.URL.Path != "/calendar/days/now.json" { - t.Errorf("request = %s %s, want the day read for now", r.Method, r.URL.Path) - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"kind":"day","starts_at":"2026-09-02T00:00:00Z","ends_at":"2026-09-02T23:59:59Z","recordings":{"Calendar::Event":[]}}`) - }), "event", "day") - if err != nil { - t.Fatalf("execute event day: %v", err) - } - if response.Summary != "0 events (today)" { - t.Errorf("summary = %q", response.Summary) - } -} - func TestEventsDayRejectsABadDate(t *testing.T) { var requests atomic.Int32 _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/cmd/events_zone.go b/internal/cmd/events_zone.go index 2bf07d61..7a60898c 100644 --- a/internal/cmd/events_zone.go +++ b/internal/cmd/events_zone.go @@ -10,33 +10,9 @@ import ( "github.com/basecamp/hey-cli/internal/timezone" ) -// eventNow is the clock an event's default date is read from, a seam for tests. -var eventNow = time.Now - -// timeZoneHint is how every refusal about a zone says what to do instead. +// timeZoneHint is how every refusal about an event's zone says what to do instead. const timeZoneHint = "pass --time-zone with an IANA zone name, for example --time-zone America/New_York" -// 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 { - read bool - name string - err error -} - -// accountTimeZone is the zone HEY's web app reads typed times in: the one the identity serves, -// which HEY keeps in step with the browser the account last signed in from. -func (f *eventFields) accountTimeZone(ctx context.Context) (string, error) { - if !f.account.read { - identity, err := rootSDK.Identity().GetIdentity(ctx) - f.account = accountZone{read: true, err: err} - if err == nil && identity != nil { - f.account.name = identity.TimeZone - } - } - return f.account.name, f.account.err -} - // writeZone is the zone a timed write's clock times are read in: --time-zone, or the HEY // account's. Nothing else is guessed at — not the machine's, which is UTC on a server and // in most sandboxes, and not UTC, which is what HEY would read a zoneless time as. @@ -49,29 +25,9 @@ func (f *eventFields) writeZone(ctx context.Context) (string, *time.Location, er return f.timeZone, loc, nil } - name, err := f.accountTimeZone(ctx) + name, loc, err := f.account.location(ctx) if err != nil { - // A failed read keeps what it failed with — network, rate limit, a server error — so - // a script can tell a retry from a mistake, and says --time-zone would do without it. - // A login HEY no longer accepts is an auth failure and nothing else: --time-zone would - // only move the refusal to the write. - readErr := *apierr.AsError(apierr.FromSDK(err)) - if readErr.Code == apierr.CodeAuth { - return "", nil, &readErr - } - readErr.Message = "no time zone to place the event in: your HEY account's time zone could not be read: " + readErr.Message - readErr.Hint = timeZoneHint - if readErr.Cause == nil { - readErr.Cause = err - } - return "", nil, &readErr - } - if name == "" { - return "", nil, errNoAccountZone("your HEY account has no time zone set", nil) - } - 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) + return "", nil, accountZoneRefusal(err, "no time zone to place the event in", timeZoneHint) } return name, loc, nil } @@ -80,12 +36,3 @@ func errInvalidTimeZone(name string) error { return apierr.ErrUsageHint(fmt.Sprintf("invalid time-zone: %s", terminal.SanitizeLine(name)), "an IANA time zone name, spelled exactly, for example America/New_York") } - -func errNoAccountZone(reason string, cause error) error { - return &apierr.Error{ - Code: apierr.CodeUsage, - Message: "no time zone to place the event in: " + reason, - Hint: timeZoneHint, - Cause: cause, - } -} diff --git a/internal/cmd/events_zone_test.go b/internal/cmd/events_zone_test.go index b694a538..2aef90af 100644 --- a/internal/cmd/events_zone_test.go +++ b/internal/cmd/events_zone_test.go @@ -89,9 +89,9 @@ func atInstant(t *testing.T, instant string) { if err != nil { t.Fatalf("instant %q: %v", instant, err) } - previous := eventNow - eventNow = func() time.Time { return at } - t.Cleanup(func() { eventNow = previous }) + previous := clockNow + clockNow = func() time.Time { return at } + t.Cleanup(func() { clockNow = previous }) } // wantSchedule checks the schedule an event write sent. An empty zone says the write must be diff --git a/internal/cmd/habit.go b/internal/cmd/habit.go index 41b46619..d6777cee 100644 --- a/internal/cmd/habit.go +++ b/internal/cmd/habit.go @@ -1,10 +1,10 @@ package cmd import ( + "context" "fmt" "strconv" "strings" - "time" "github.com/spf13/cobra" @@ -26,7 +26,7 @@ func newHabitCommand() *habitCommand { Use: "habit", Short: "Create and manage habits", Annotations: map[string]string{ - "agent_notes": "Subcommands: list, create, edit, delete, complete, uncomplete. Use list --ids-only to pipe IDs to the rest. Days accept weekday names or 0 (Sunday) through 6 (Saturday).", + "agent_notes": "Subcommands: list, create, edit, delete, complete, uncomplete. Use list --ids-only to pipe IDs to the rest. Days accept weekday names or 0 (Sunday) through 6 (Saturday). Without --date, list, complete and uncomplete use today in the HEY account's time zone; complete and uncomplete refuse when the account has none, so pass --date.", }, } @@ -57,7 +57,11 @@ func newHabitListCommand() *habitListCommand { Long: `List habits. A habit is read from the week a date falls in. A week lists each habit once, whatever -weekday it runs on; a week that has not started yet lists none.`, +weekday it runs on; a week that has not started yet lists none. + +Without --date the week is the one today falls in, today in your HEY account's time zone, +whatever this machine's is. An account with no time zone set reads this machine's today, +and says so on stderr.`, Example: ` hey habit list hey habit list --date 2026-09-02 hey habit list --ids-only`, @@ -77,15 +81,20 @@ func (c *habitListCommand) run(cmd *cobra.Command, args []string) error { return err } + ctx := cmd.Context() date := c.date if date == "" { - date = time.Now().Format(dateLayout) + var account accountZone + today, err := account.todayToRead(ctx, cmd.ErrOrStderr(), "name a day in the week, for example --date 2026-10-14") + if err != nil { + return err + } + date = today.Format(dateLayout) } if _, err := parseDateArg("date", date); err != nil { return err } - ctx := cmd.Context() week, err := sdk.CalendarPeriods().Week(ctx, date) if err != nil { return apierr.FromSDK(err) @@ -360,6 +369,10 @@ func newHabitCompleteCommand() *habitCompleteCommand { habitCompleteCommand.cmd = &cobra.Command{ Use: "complete ", Short: "Mark a habit as complete for a date", + Long: `Mark a habit as complete for a date. + +Without --date the day is today in your HEY account's time zone, whatever this machine's +is. An account with no time zone set is refused rather than guessed at: pass --date.`, Example: ` hey habit complete 789 hey habit complete 789 --date 2026-01-15`, RunE: habitCompleteCommand.run, @@ -381,7 +394,7 @@ func (c *habitCompleteCommand) run(cmd *cobra.Command, args []string) error { return err } - date, err := habitCompletionDate(c.date) + date, err := habitCompletionDate(cmd.Context(), c.date) if err != nil { return err } @@ -410,6 +423,10 @@ func newHabitUncompleteCommand() *habitUncompleteCommand { habitUncompleteCommand.cmd = &cobra.Command{ Use: "uncomplete ", Short: "Remove a habit completion for a date", + Long: `Remove a habit completion for a date. + +Without --date the day is today in your HEY account's time zone, whatever this machine's +is. An account with no time zone set is refused rather than guessed at: pass --date.`, Example: ` hey habit uncomplete 789 hey habit uncomplete 789 --date 2026-01-15`, RunE: habitUncompleteCommand.run, @@ -431,7 +448,7 @@ func (c *habitUncompleteCommand) run(cmd *cobra.Command, args []string) error { return err } - date, err := habitCompletionDate(c.date) + date, err := habitCompletionDate(cmd.Context(), c.date) if err != nil { return err } @@ -448,11 +465,18 @@ func (c *habitUncompleteCommand) run(cmd *cobra.Command, args []string) error { result) } -// habitCompletionDate reads the --date flag, defaulting to today. A date the server -// cannot read would otherwise be sent as a URL segment and answered with a 404. -func habitCompletionDate(flag string) (string, error) { +// habitCompletionDate reads the --date flag, defaulting to today in the account's zone — +// refused rather than guessed at when the account has none, since marking the wrong day +// done is a write. A date the server cannot read would otherwise be sent as a URL segment +// and answered with a 404. +func habitCompletionDate(ctx context.Context, flag string) (string, error) { if flag == "" { - return time.Now().Format(dateLayout), nil + var account accountZone + today, err := account.todayToWrite(ctx, "pass the day with --date, for example --date 2026-10-14") + if err != nil { + return "", err + } + return today.Format(dateLayout), nil } if _, err := parseDateArg("date", flag); err != nil { return "", err diff --git a/internal/cmd/journal.go b/internal/cmd/journal.go index e605422a..18e76f06 100644 --- a/internal/cmd/journal.go +++ b/internal/cmd/journal.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "strings" - "time" "github.com/spf13/cobra" @@ -25,7 +24,7 @@ func newJournalCommand() *journalCommand { Use: "journal", Short: "Read and write journal entries", Annotations: map[string]string{ - "agent_notes": "Subcommands: list, read, write. Read defaults to today; its JSON answers content (HTML as HEY serves it), content_markdown (the form write takes) and content_markdown_lossless; when that is false, change content and write it with --content-html instead. Write replaces the whole entry and accepts --content, stdin, or opens $EDITOR (refused for an entry whose Markdown is not lossless); content is Markdown, or raw HTML via --content-html.", + "agent_notes": "Subcommands: list, read, write. Read and write default to today in the HEY account's time zone; write refuses without one, so name the date. Read JSON answers content (HTML as HEY serves it), content_markdown (the form write takes) and content_markdown_lossless; when that is false, change content and write it with --content-html instead. Write replaces the whole entry and accepts --content, stdin, or opens $EDITOR (refused for an entry whose Markdown is not lossless); content is Markdown, or raw HTML via --content-html.", }, } @@ -133,6 +132,9 @@ func newJournalReadCommand() *journalReadCommand { Short: "Read a journal entry (default: today)", Long: `Read a journal entry, today's by default. +Without a date the day is today in your HEY account's time zone, whatever this machine's +is. An account with no time zone set reads this machine's today, and says so on stderr. + JSON answers content, the entry's HTML as HEY serves it; content_markdown, the entry as Markdown; and content_markdown_lossless, which says whether that Markdown holds everything in the entry. Write content_markdown back with hey journal write only when @@ -156,15 +158,22 @@ func (c *journalReadCommand) run(cmd *cobra.Command, args []string) error { return err } - date := time.Now().Format(dateLayout) + ctx := cmd.Context() + var date string if len(args) > 0 { if _, err := parseDateArg("date", args[0]); err != nil { return err } date = args[0] + } else { + var account accountZone + today, err := account.todayToRead(ctx, cmd.ErrOrStderr(), "name the day, for example hey journal read 2026-10-14") + if err != nil { + return err + } + date = today.Format(dateLayout) } - ctx := cmd.Context() content, err := sdk.Journal().GetContent(ctx, date) if err != nil { return apierr.FromSDK(err) @@ -227,6 +236,9 @@ func newJournalWriteCommand() *journalWriteCommand { Short: "Write or edit a journal entry (default: today)", Long: `Write or edit a journal entry, today's by default. +Without a date the day is today in your HEY account's time zone, whatever this machine's +is. An account with no time zone set is refused rather than guessed at: name the date. + Content that trims to nothing — whitespace-only, or an emptied $EDITOR buffer — removes the day's entry, and the command says "removed" rather than "saved". Omitting content reads stdin when it is not a terminal, and otherwise opens $EDITOR on the day's existing entry as @@ -287,8 +299,15 @@ func (c *journalWriteCommand) run(cmd *cobra.Command, args []string) error { } ctx := cmd.Context() + // Today is the account's, and without it the write is refused before any content is + // read or an editor opened: writing over the wrong day's entry is not a guess to make. if date == "" { - date = time.Now().Format(dateLayout) + var account accountZone + today, err := account.todayToWrite(ctx, `name the day first, for example hey journal write 2026-10-14 "Shipped the pagination fix"`) + if err != nil { + return err + } + date = today.Format(dateLayout) } if c.contentHTML != "" { diff --git a/internal/cmd/journal_test.go b/internal/cmd/journal_test.go index 54066a9b..02e93573 100644 --- a/internal/cmd/journal_test.go +++ b/internal/cmd/journal_test.go @@ -32,6 +32,9 @@ func journalServerWithReadBehavior(t *testing.T, readBehavior string) *httptest. t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { + case r.Method == "GET" && r.URL.Path == "/identity.json": + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, newYorkIdentity) case r.Method == "GET" && strings.Contains(r.URL.Path, "/calendar/days/") && strings.HasSuffix(r.URL.Path, "/journal_entry/edit"): // Legacy HTML-scrape path w.Header().Set("Content-Type", "text/html") diff --git a/internal/cmd/mutation_test.go b/internal/cmd/mutation_test.go index d90df6e0..1870bbd1 100644 --- a/internal/cmd/mutation_test.go +++ b/internal/cmd/mutation_test.go @@ -52,7 +52,7 @@ func TestMutationEnvelopeCarriesSummaryAndBreadcrumbs(t *testing.T) { func TestMutationOmitsDataTheAPIDidNotSend(t *testing.T) { response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) - }), "todo", "add", "Buy milk") + }), "todo", "add", "Buy milk", "--date", "2026-10-14") if err != nil { t.Fatalf("execute todo add: %v", err) } diff --git a/internal/cmd/recording_filter.go b/internal/cmd/recording_filter.go index 10d22ff2..4b4a0002 100644 --- a/internal/cmd/recording_filter.go +++ b/internal/cmd/recording_filter.go @@ -27,7 +27,12 @@ type recordingFilter struct { // defaultWindow is the span to read when no dates are given. An event looks ahead from // today, the way a calendar is read; a to-do or a journal entry looks back over years, // because that is where the ones worth listing already are. - defaultWindow func(now time.Time) (time.Time, time.Time) + defaultWindow func(today time.Time) (time.Time, time.Time) + + // today is the day a default window is laid around when no start is given. Without it + // that is the machine's today, which is close enough for a window that reaches years + // either way; a window that starts at today has to ask the account. + today func(ctx context.Context) (time.Time, error) // defaultCalendars is where to look when --calendar is not given. Events are spread over // every calendar the identity has; to-dos and journal entries live on the personal one. @@ -36,14 +41,14 @@ type recordingFilter struct { // eventWindow reads from today forward, which is what somebody asking what is on their // calendar means. -func eventWindow(now time.Time) (time.Time, time.Time) { - return now, now.AddDate(0, 0, 30) +func eventWindow(today time.Time) (time.Time, time.Time) { + return today, today.AddDate(0, 0, 30) } // personalWindow reads years back and a year forward. A to-do or a journal entry is looked up // by having been written rather than by coming up, so its window is wide. -func personalWindow(now time.Time) (time.Time, time.Time) { - return now.AddDate(-4, 0, 0), now.AddDate(1, 0, 0) +func personalWindow(today time.Time) (time.Time, time.Time) { + return today.AddDate(-4, 0, 0), today.AddDate(1, 0, 0) } // registerFlags puts the window on a command. calendarUsage says where the listing looks @@ -66,7 +71,21 @@ type recordingWindow struct { } func (f *recordingFilter) resolve(ctx context.Context) (recordingWindow, error) { - defaultStart, defaultEnd := f.defaultWindow(time.Now()) + // A date that cannot be read is refused before today is asked for. + if f.endsOn != "" { + if _, err := parseDateArg("ends-on date", f.endsOn); err != nil { + return recordingWindow{}, err + } + } + + today := calendarDay(clockNow()) + if f.startsOn == "" && f.today != nil { + var err error + if today, err = f.today(ctx); err != nil { + return recordingWindow{}, err + } + } + defaultStart, defaultEnd := f.defaultWindow(today) startsOn := f.startsOn if startsOn == "" { diff --git a/internal/cmd/todo.go b/internal/cmd/todo.go index 789bc31a..88061896 100644 --- a/internal/cmd/todo.go +++ b/internal/cmd/todo.go @@ -19,7 +19,7 @@ func newTodoCommand() *todoCommand { Use: "todo", Short: "Create and manage to-dos", Annotations: map[string]string{ - "agent_notes": "Subcommands: list, add, complete, uncomplete, delete. Use list --ids-only to pipe IDs to complete/delete.", + "agent_notes": "Subcommands: list, add, complete, uncomplete, delete. Use list --ids-only to pipe IDs to complete/delete. add files on today in the HEY account's time zone without --date, and refuses when the account has none, so pass --date.", }, } @@ -133,6 +133,10 @@ func newTodoAddCommand() *todoAddCommand { todoAddCommand.cmd = &cobra.Command{ Use: "add [title]", Short: "Create a new todo", + Long: `Create a new todo, filed on --date or on today. + +Without --date the day is today in your HEY account's time zone, whatever this machine's +is. An account with no time zone set is refused rather than guessed at: pass --date.`, Example: ` hey todo add "Buy groceries" hey todo add -t "Meeting prep" --date 2026-01-20 hey todo add --title "Review PR" --json @@ -142,7 +146,7 @@ func newTodoAddCommand() *todoAddCommand { } todoAddCommand.cmd.Flags().StringVarP(&todoAddCommand.title, "title", "t", "", "Todo title") - todoAddCommand.cmd.Flags().StringVar(&todoAddCommand.date, "date", "", "Due date (YYYY-MM-DD)") + todoAddCommand.cmd.Flags().StringVar(&todoAddCommand.date, "date", "", "Due date (YYYY-MM-DD, defaults to today)") return todoAddCommand } @@ -177,8 +181,20 @@ func (c *todoAddCommand) run(cmd *cobra.Command, args []string) error { "hey todo add \"Buy milk\" or hey todo add --title \"Buy milk\"") } + // The SDK files a to-do given no date on the machine's today, which is UTC on a server, + // so today is named here, in the account's zone, and refused without one. ctx := cmd.Context() - result, err := sdk.CalendarTodos().Create(ctx, title, c.date) + date := c.date + if date == "" { + var account accountZone + today, err := account.todayToWrite(ctx, "pass the day with --date, for example --date 2026-10-14") + if err != nil { + return err + } + date = today.Format(dateLayout) + } + + result, err := sdk.CalendarTodos().Create(ctx, title, date) if err != nil { return apierr.FromSDK(err) } diff --git a/internal/cmd/todo_test.go b/internal/cmd/todo_test.go index 03e5d87a..bf0df981 100644 --- a/internal/cmd/todo_test.go +++ b/internal/cmd/todo_test.go @@ -17,6 +17,9 @@ func todoServer(t *testing.T) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { + case r.Method == "GET" && r.URL.Path == "/identity.json": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, newYorkIdentity) case r.Method == "POST" && r.URL.Path == "/calendar/todos.json": body, _ := io.ReadAll(r.Body) var req map[string]any diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 32e67f4e..39cd1faf 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -667,7 +667,7 @@ hey bubble list # List bubbled-up and scheduled th hey bubble pop 12345 # Cancel a bubble-up: the thread moves to the Imbox, seen ``` -Takes box item IDs (the `id` field from `hey box view --json`). `hey bubble up` requires exactly one of `--now`, `--on`, `--tomorrow`, `--weekend`, and `--next-week`. `--on` takes a YYYY-MM-DD date; HEY bubbles the threads up at 08:00 UTC that day, or at 18:00 UTC when the date is today by your local clock. HEY schedules in UTC, so for a reader far from UTC "morning" may be the night before or the afternoon. `hey bubble pop` does not return a thread to its original box; it moves it to the Imbox and marks it seen. +Takes box item IDs (the `id` field from `hey box view --json`). `hey bubble up` requires exactly one of `--now`, `--on`, `--tomorrow`, `--weekend`, and `--next-week`. `--on` takes a YYYY-MM-DD date; HEY bubbles the threads up at 08:00 UTC that day, or at 18:00 UTC when the date is today in UTC. HEY schedules in UTC, so for a reader far from UTC "morning" may be the night before or the afternoon. `hey bubble pop` does not return a thread to its original box; it moves it to the Imbox and marks it seen. `hey bubble list --json` answers two buckets: `bubbled_up`, the threads back in the Imbox after bubbling up, and `scheduled`, the threads waiting in Bubble Up — each scheduled row carries `bubble_up_schedule.bubble_up_at`, and `surprise_me` when HEY picked the time. Use `id` with `hey bubble pop`, `topic_id` with `hey thread read`. @@ -791,6 +791,13 @@ unreadable one, or an `--ends-on` before `--starts-on`, is a usage error rather empty result. Naming only `--starts-on` moves the whole window rather than reading up to the default end. +**Today is the HEY account's today.** Every command that defaults to today (`event +day`/`week`/`list`, `habit list`/`complete`/`uncomplete`, `journal read`/`write`, `todo +add`) works it out in the account's time zone and sends the date, so a UTC machine in the +New York evening still gets the New York day. Name the date to skip the account read. With +no account zone, a read uses the machine's date and says so on stderr; a write refuses — +pass the date. + ### Events ```bash