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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ Snippets are named reusable email content, separate from clips saved out of rece

`hey box view <name|id>`, `hey label view <id>` and `hey collection view <id>` 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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
138 changes: 138 additions & 0 deletions internal/cmd/account_zone.go
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
robzolkos marked this conversation as resolved.
*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
}
Loading
Loading