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
5 changes: 5 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,11 @@ hey spam
hey stop-ignoring
hey threads
hey timetrack
hey timetrack categories
hey timetrack category
hey timetrack category create
hey timetrack category delete
hey timetrack category rename
hey timetrack current
hey timetrack list
hey timetrack list --all
Expand Down
4 changes: 4 additions & 0 deletions API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ The remaining HTML-reading gaps use the SDK's authenticated HTML helper and are
| `/calendar/ongoing_time_track.json` | GET | SDK `TimeTracks().GetOngoing` | `hey timetrack current` | covered |
| `/calendar/ongoing_time_track.json` | POST | SDK `TimeTracks().Start` | `hey timetrack start` | covered |
| `/calendar/time_tracks/{id}.json` | PUT | SDK `TimeTracks().Stop` | `hey timetrack stop` | covered |
| `/calendar/time_tracks/categories.json` | GET | SDK `TimeTracks().Categories` | `hey timetrack categories`, Calendar TUI `c` | covered |
| `/calendar/time_tracks/categories` | POST | SDK `TimeTracks().CreateCategory` | `hey timetrack category create`, Calendar TUI `c` | covered |
| `/calendar/time_tracks/categories/{id}` | PATCH | SDK `TimeTracks().UpdateCategory` | `hey timetrack category rename`, Calendar TUI `c` | covered |
| `/calendar/time_tracks/categories/{id}` | DELETE | SDK `TimeTracks().DeleteCategory` | `hey timetrack category delete`, Calendar TUI `c` | covered |
| `/calendar/todos.json` | POST | SDK `CalendarTodos().Create` | `hey todo add` | covered |
| `/calendar/todos/{id}/completions.json` | POST | SDK `CalendarTodos().Complete` | `hey todo complete <id>` | covered |
| `/calendar/todos/{id}/completions.json` | DELETE | SDK `CalendarTodos().Uncomplete` | `hey todo uncomplete <id>` | covered |
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ Thread attachments always appear with their filename, media type, and size. Use

Press Shift+O to open Contacts. Use Enter to view a contact, `a` to add, `e` to edit, `n` to edit the private note, `x` twice to delete a note, `h` to hide, and `u` to show the most recently hidden contact again. Escape or `q` goes back.

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.

## CLI Commands

Structured data commands support `--json` for full output and `--jq '<expression>'` to
Expand Down Expand Up @@ -332,6 +334,10 @@ hey timetrack start # start tracking
hey timetrack stop # stop tracking
hey timetrack current # show active track
hey timetrack list # list all tracks
hey timetrack categories # list categories
hey timetrack category create "Client work"
hey timetrack category rename 123 "Planning"
hey timetrack category delete 123
```

### Journal
Expand Down
139 changes: 139 additions & 0 deletions internal/cmd/calendar_commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,3 +351,142 @@ func TestTimetrackListCommand(t *testing.T) {
t.Errorf("notice = %q", response.Notice)
}
}

func TestTimetrackCategoriesCommand(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/time_tracks/categories.json" {
t.Errorf("request = %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `[{"id":31,"title":"Client work"},{"id":32,"title":"Planning"}]`)
}), "timetrack", "categories")
if err != nil {
t.Fatalf("execute timetrack categories: %v", err)
}
if response.Summary != "2 time track categories" {
t.Errorf("summary = %q", response.Summary)
}
if len(response.Breadcrumbs) != 1 || response.Breadcrumbs[0].Action != "create" {
t.Errorf("breadcrumbs = %#v", response.Breadcrumbs)
}
}

func TestTimetrackCategoryStyledOutputSanitizesTitles(t *testing.T) {
dangerous := "Client\x1b]52;c;secret\a\nForged"
assertSafe := func(t *testing.T, stdout string) {
t.Helper()
if strings.Contains(stdout, "\x1b]52") || strings.ContainsRune(stdout, '\a') || strings.Contains(stdout, "\nForged") {
t.Errorf("styled category output retained terminal controls: %q", stdout)
}
}

t.Run("list", func(t *testing.T) {
stdout, err := runStyledCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]map[string]any{{"id": 31, "title": dangerous}})
}), "timetrack", "categories")
if err != nil {
t.Fatalf("execute timetrack categories: %v", err)
}
assertSafe(t, stdout)
})

t.Run("mutation", func(t *testing.T) {
stdout, err := runStyledCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Fatalf("parse form: %v", err)
}
if got := r.Form.Get("category[title]"); got != dangerous {
t.Errorf("category title = %q, want unchanged %q", got, dangerous)
}
w.WriteHeader(http.StatusNoContent)
}), "timetrack", "category", "create", dangerous)
if err != nil {
t.Fatalf("execute category create: %v", err)
}
assertSafe(t, stdout)
})
}

func TestTimetrackCategoryMutations(t *testing.T) {
tests := []struct {
name string
args []string
method string
path string
title string
wantSummary string
}{
{
name: "create",
args: []string{"timetrack", "category", "create", "Client work"},
method: http.MethodPost,
path: "/calendar/time_tracks/categories",
title: "Client work",
wantSummary: `Time track category "Client work" created`,
},
{
name: "rename",
args: []string{"timetrack", "category", "rename", "31", "Planning"},
method: http.MethodPatch,
path: "/calendar/time_tracks/categories/31",
title: "Planning",
wantSummary: `Time track category 31 renamed to "Planning"`,
},
{
name: "delete",
args: []string{"timetrack", "category", "delete", "31"},
method: http.MethodDelete,
path: "/calendar/time_tracks/categories/31",
wantSummary: "Time track category 31 deleted",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != tt.method || r.URL.Path != tt.path {
t.Errorf("request = %s %s, want %s %s", r.Method, r.URL.Path, tt.method, tt.path)
}
if tt.title != "" {
if err := r.ParseForm(); err != nil {
t.Fatalf("parse form: %v", err)
}
if got := r.Form.Get("category[title]"); got != tt.title {
t.Errorf("category title = %q, want %q", got, tt.title)
}
}
w.WriteHeader(http.StatusNoContent)
}), tt.args...)
if err != nil {
t.Fatalf("execute %s: %v", tt.name, err)
}
if response.Summary != tt.wantSummary {
t.Errorf("summary = %q, want %q", response.Summary, tt.wantSummary)
}
if len(response.Breadcrumbs) != 1 || response.Breadcrumbs[0].Action != "list" {
t.Errorf("breadcrumbs = %#v", response.Breadcrumbs)
}
})
}
}

func TestTimetrackCategoryMutationValidationMakesNoRequest(t *testing.T) {
var requests atomic.Int32
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
})

for _, args := range [][]string{
{"timetrack", "category", "create", " "},
{"timetrack", "category", "rename", "invalid", "Planning"},
{"timetrack", "category", "delete", "0"},
} {
if _, err := runJSONCommand(t, handler, args...); err == nil {
t.Errorf("%v: expected validation error", args)
}
}
if requests.Load() != 0 {
t.Errorf("requests = %d, want 0", requests.Load())
}
}
Loading
Loading