diff --git a/.surface b/.surface index 74788803..6019717f 100644 --- a/.surface +++ b/.surface @@ -94,6 +94,17 @@ hey forward --to hey habit hey habit complete hey habit complete --date +hey habit create +hey habit create --color +hey habit create --days +hey habit create --icon +hey habit create --name +hey habit delete +hey habit edit +hey habit edit --color +hey habit edit --days +hey habit edit --icon +hey habit edit --name hey habit uncomplete hey habit uncomplete --date hey ignore diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 03a1110c..c5bac863 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -53,6 +53,9 @@ The remaining HTML-reading gaps use the SDK's authenticated HTML helper and are | `/postings/spam.json` | POST | SDK `Postings().MarkSpam` | `hey spam `, TUI `s` | covered | | `/postings/mutings.json` | POST | SDK `Postings().Mute` | `hey ignore `, TUI `-` | covered | | `/postings/mutings.json` | DELETE | SDK `Postings().Unmute` | `hey stop-ignoring `, TUI `+` | covered | +| `/calendar/habits.json` | POST | SDK `Habits().Create` | `hey habit create`, Calendar TUI `a` | covered | +| `/calendar/habits/{id}.json` | PATCH | SDK `Habits().Update` | `hey habit edit `, Calendar TUI `e` | covered | +| `/calendar/habits/{id}.json` | DELETE | SDK `Habits().Delete` | `hey habit delete `, Calendar TUI `x` | covered | | `/calendar/days/{date}/habits/{id}/completions.json` | POST | SDK `Habits().Complete` | `hey habit complete ` | covered | | `/calendar/days/{date}/habits/{id}/completions.json` | DELETE | SDK `Habits().Uncomplete` | `hey habit uncomplete ` | covered | | `/calendar/days/{date}/journal_entry.json` | GET | SDK `Journal().Get` | `hey journal read [date]` | partial: falls back to legacy | diff --git a/README.md b/README.md index 7b0aceb3..987cff93 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,8 @@ Press Shift+O to open Contacts. Use Enter to view a contact, `a` to add, `e` to 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. +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. + ## CLI Commands Structured data commands support `--json` for full output and `--jq ''` to @@ -325,10 +327,17 @@ hey todo delete 1 # delete ### Habits ```bash -hey habit complete 1 # mark habit done (today or --date YYYY-MM-DD) -hey habit uncomplete 1 # undo habit completion +hey habit create "Morning strength training" # create every day with weights and blue defaults +hey habit create "Practice piano" --icon music --color green --days mon,wed,fri +hey habit edit 1 --name "Evening strength training" # edit only the supplied fields +hey habit edit 1 --days 0,6 # Sunday and Saturday (names also work) +hey habit delete 1 # permanently delete the habit and its history +hey habit complete 1 # mark habit done (today or --date YYYY-MM-DD) +hey habit uncomplete 1 # undo habit completion ``` +Habit IDs come from calendar recordings. Weekdays use `0` for Sunday through `6` for Saturday; full names and common abbreviations are accepted too. + ### Time tracking ```bash diff --git a/internal/cmd/habit.go b/internal/cmd/habit.go index ae7faa8d..d18f317f 100644 --- a/internal/cmd/habit.go +++ b/internal/cmd/habit.go @@ -3,10 +3,14 @@ package cmd import ( "fmt" "strconv" + "strings" "time" "github.com/spf13/cobra" + hey "github.com/basecamp/hey-sdk/go/pkg/hey" + + habitvalues "github.com/basecamp/hey-cli/internal/habit" "github.com/basecamp/hey-cli/internal/output" ) @@ -18,18 +22,248 @@ func newHabitCommand() *habitCommand { habitCommand := &habitCommand{} habitCommand.cmd = &cobra.Command{ Use: "habit", - Short: "Track completed habits", + Short: "Create and manage habits", Annotations: map[string]string{ - "agent_notes": "Subcommands: complete, uncomplete. Requires habit ID from calendar recordings.", + "agent_notes": "Subcommands: create, edit, delete, complete, uncomplete. Habit IDs are available in calendar recordings. Days accept weekday names or 0 (Sunday) through 6 (Saturday).", }, } + habitCommand.cmd.AddCommand(newHabitCreateCommand().cmd) + habitCommand.cmd.AddCommand(newHabitEditCommand().cmd) + habitCommand.cmd.AddCommand(newHabitDeleteCommand().cmd) habitCommand.cmd.AddCommand(newHabitCompleteCommand().cmd) habitCommand.cmd.AddCommand(newHabitUncompleteCommand().cmd) return habitCommand } +// create + +type habitCreateCommand struct { + cmd *cobra.Command + name string + icon string + color string + days string +} + +func newHabitCreateCommand() *habitCreateCommand { + habitCreateCommand := &habitCreateCommand{} + habitCreateCommand.cmd = &cobra.Command{ + Use: "create [name]", + Short: "Create a habit", + Example: ` hey habit create "Morning strength training" + hey habit create --name "Practice piano" --icon music --color green --days monday,wednesday,friday + echo "Read for thirty minutes" | hey habit create`, + RunE: habitCreateCommand.run, + Args: cobra.MaximumNArgs(1), + } + + habitCreateCommand.cmd.Flags().StringVar(&habitCreateCommand.name, "name", "", "Habit name") + habitCreateCommand.cmd.Flags().StringVar(&habitCreateCommand.icon, "icon", habitvalues.DefaultIcon, "Habit icon. Accepted values: "+habitvalues.IconValues) + habitCreateCommand.cmd.Flags().StringVar(&habitCreateCommand.color, "color", habitvalues.DefaultColor, "Habit color. Accepted values: "+habitvalues.ColorValues) + habitCreateCommand.cmd.Flags().StringVar(&habitCreateCommand.days, "days", habitvalues.FormatDays(habitvalues.EveryDay), "Weekdays (names or 0-6, comma-separated)") + + return habitCreateCommand +} + +func (c *habitCreateCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + name := strings.TrimSpace(c.name) + nameFlagSet := cmd.Flags().Changed("name") + if nameFlagSet && len(args) > 0 { + return output.ErrUsage("--name and positional argument are mutually exclusive") + } + if len(args) > 0 { + name = strings.TrimSpace(args[0]) + } + if name == "" && !nameFlagSet && len(args) == 0 && !stdinIsTerminal() { + var err error + name, err = readStdin() + if err != nil { + return err + } + name = strings.TrimSpace(name) + } + if name == "" { + return output.ErrUsageHint("name is required", "hey habit create \"Morning strength training\"") + } + icon := strings.TrimSpace(c.icon) + color := strings.TrimSpace(c.color) + if icon == "" || color == "" { + return output.ErrUsage("icon and color cannot be empty") + } + if err := habitvalues.ValidateIcon(icon); err != nil { + return output.ErrUsage(err.Error()) + } + if err := habitvalues.ValidateColor(color); err != nil { + return output.ErrUsage(err.Error()) + } + days, err := habitvalues.ParseDays(c.days) + if err != nil { + return output.ErrUsage(err.Error()) + } + + recording, err := sdk.Habits().Create(cmd.Context(), hey.HabitParams{Name: name, Icon: icon, Color: color, Days: days}) + if err != nil { + return convertSDKError(err) + } + if writer.IsStyled() { + fmt.Fprintf(cmd.OutOrStdout(), "Habit %q created.\n", name) + return nil + } + return writeHabitMutation(recording, "Habit created") +} + +// edit + +type habitEditCommand struct { + cmd *cobra.Command + name string + icon string + color string + days string +} + +func newHabitEditCommand() *habitEditCommand { + habitEditCommand := &habitEditCommand{} + habitEditCommand.cmd = &cobra.Command{ + Use: "edit [name]", + Aliases: []string{"update"}, + Short: "Edit a habit", + Example: ` hey habit edit 789 --name "Evening walk" + hey habit edit 789 --days monday,tuesday,wednesday,thursday,friday + hey habit update 789 --icon walk --color gold`, + RunE: habitEditCommand.run, + Args: cobra.RangeArgs(1, 2), + } + + habitEditCommand.cmd.Flags().StringVar(&habitEditCommand.name, "name", "", "New habit name") + habitEditCommand.cmd.Flags().StringVar(&habitEditCommand.icon, "icon", "", "New habit icon. Accepted values: "+habitvalues.IconValues) + habitEditCommand.cmd.Flags().StringVar(&habitEditCommand.color, "color", "", "New habit color. Accepted values: "+habitvalues.ColorValues) + habitEditCommand.cmd.Flags().StringVar(&habitEditCommand.days, "days", "", "New weekdays (names or 0-6, comma-separated)") + + return habitEditCommand +} + +func (c *habitEditCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + id, err := parseHabitID(args[0]) + if err != nil { + return err + } + nameChanged := cmd.Flags().Changed("name") || len(args) == 2 + if cmd.Flags().Changed("name") && len(args) == 2 { + return output.ErrUsage("--name and positional name are mutually exclusive") + } + name := strings.TrimSpace(c.name) + if len(args) == 2 { + name = strings.TrimSpace(args[1]) + } + iconChanged := cmd.Flags().Changed("icon") + colorChanged := cmd.Flags().Changed("color") + daysChanged := cmd.Flags().Changed("days") + if !nameChanged && !iconChanged && !colorChanged && !daysChanged { + return output.ErrUsage("provide at least one of name, --icon, --color, or --days") + } + if nameChanged && name == "" { + return output.ErrUsage("name cannot be empty") + } + icon := strings.TrimSpace(c.icon) + color := strings.TrimSpace(c.color) + if iconChanged && icon == "" { + return output.ErrUsage("icon cannot be empty") + } + if iconChanged { + if validationErr := habitvalues.ValidateIcon(icon); validationErr != nil { + return output.ErrUsage(validationErr.Error()) + } + } + if colorChanged && color == "" { + return output.ErrUsage("color cannot be empty") + } + if colorChanged { + if validationErr := habitvalues.ValidateColor(color); validationErr != nil { + return output.ErrUsage(validationErr.Error()) + } + } + var days []int32 + if daysChanged { + days, err = habitvalues.ParseDays(c.days) + if err != nil { + return output.ErrUsage(err.Error()) + } + } + + recording, err := sdk.Habits().Update(cmd.Context(), id, hey.HabitParams{Name: name, Icon: icon, Color: color, Days: days}) + if err != nil { + return convertSDKError(err) + } + if writer.IsStyled() { + fmt.Fprintf(cmd.OutOrStdout(), "Habit %d updated.\n", id) + return nil + } + return writeHabitMutation(recording, "Habit updated") +} + +// delete + +type habitDeleteCommand struct { + cmd *cobra.Command +} + +func newHabitDeleteCommand() *habitDeleteCommand { + habitDeleteCommand := &habitDeleteCommand{} + habitDeleteCommand.cmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a habit and its history", + Example: ` hey habit delete 789`, + RunE: habitDeleteCommand.run, + Args: usageExactOneArg(), + } + return habitDeleteCommand +} + +func (c *habitDeleteCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + id, err := parseHabitID(args[0]) + if err != nil { + return err + } + if err := sdk.Habits().Delete(cmd.Context(), id); err != nil { + return convertSDKError(err) + } + if writer.IsStyled() { + fmt.Fprintf(cmd.OutOrStdout(), "Habit %d deleted.\n", id) + return nil + } + return writeOK(nil, output.WithSummary("Habit deleted")) +} + +func parseHabitID(value string) (int64, error) { + id, err := strconv.ParseInt(value, 10, 64) + if err != nil || id <= 0 { + return 0, output.ErrUsage(fmt.Sprintf("invalid habit ID: %s", value)) + } + return id, nil +} + +func writeHabitMutation(recording any, summary string) error { + normalized, err := normalizeAny(recording) + if err != nil { + return writeOK(nil, output.WithSummary(summary)) + } + return writeOK(normalized, output.WithSummary(summary)) +} + // complete type habitCompleteCommand struct { @@ -58,9 +292,9 @@ func (c *habitCompleteCommand) run(cmd *cobra.Command, args []string) error { return err } - id, err := strconv.ParseInt(args[0], 10, 64) + id, err := parseHabitID(args[0]) if err != nil { - return output.ErrUsage(fmt.Sprintf("invalid habit ID: %s", args[0])) + return err } date := c.date @@ -114,9 +348,9 @@ func (c *habitUncompleteCommand) run(cmd *cobra.Command, args []string) error { return err } - id, err := strconv.ParseInt(args[0], 10, 64) + id, err := parseHabitID(args[0]) if err != nil { - return output.ErrUsage(fmt.Sprintf("invalid habit ID: %s", args[0])) + return err } date := c.date diff --git a/internal/cmd/habit_test.go b/internal/cmd/habit_test.go new file mode 100644 index 00000000..22f13338 --- /dev/null +++ b/internal/cmd/habit_test.go @@ -0,0 +1,145 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + + "github.com/spf13/cobra" + + habitvalues "github.com/basecamp/hey-cli/internal/habit" +) + +func TestHabitCreateCommand(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/calendar/habits.json" { + t.Errorf("request = %s %s, want POST /calendar/habits.json", r.Method, r.URL.Path) + } + var body struct { + Habit struct { + Name string `json:"name"` + Icon string `json:"icon"` + Color string `json:"color"` + Days []int32 `json:"days"` + } `json:"calendar_habit"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Habit.Name != "Morning strength training" || body.Habit.Icon != "weights" || body.Habit.Color != "blue" { + t.Errorf("payload = %+v", body.Habit) + } + if got := body.Habit.Days; len(got) != 7 || got[0] != 0 || got[6] != 6 { + t.Errorf("default days = %v, want 0 through 6", got) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"id":42,"title":"Morning strength training","type":"CalendarHabit","icon":"weights","color":"blue","days":[0,1,2,3,4,5,6]}`) + }), "habit", "create", "Morning strength training") + if err != nil { + t.Fatalf("execute habit create: %v", err) + } + if response.Summary != "Habit created" { + t.Errorf("summary = %q", response.Summary) + } + data, ok := response.Data.(map[string]any) + if !ok || data["id"] != float64(42) || data["title"] != "Morning strength training" { + t.Errorf("structured data = %#v", response.Data) + } +} + +func TestHabitCreateParsesFriendlyDays(t *testing.T) { + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + days := body["calendar_habit"]["days"].([]any) + if len(days) != 3 || days[0] != float64(1) || days[1] != float64(3) || days[2] != float64(5) { + t.Errorf("days = %v, want [1 3 5]", days) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"id":43,"title":"Practice piano","type":"CalendarHabit"}`) + }), "habit", "create", "Practice piano", "--icon", "music", "--color", "green", "--days", "Friday, monday, WED") + if err != nil { + t.Fatalf("execute habit create: %v", err) + } +} + +func TestHabitEditSendsPartialPayload(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || r.URL.Path != "/calendar/habits/42.json" { + t.Errorf("request = %s %s, want PATCH /calendar/habits/42.json", r.Method, r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + if got := string(body); got != `{"calendar_habit":{"color":"gold","days":[0,6]}}` { + t.Errorf("payload = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":42,"title":"Morning strength training","type":"CalendarHabit","color":"gold","days":[0,6]}`) + }), "habit", "edit", "42", "--color", "gold", "--days", "Saturday,0") + if err != nil { + t.Fatalf("execute habit edit: %v", err) + } + if response.Summary != "Habit updated" { + t.Errorf("summary = %q", response.Summary) + } +} + +func TestHabitDeleteCommand(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || r.URL.Path != "/calendar/habits/42.json" { + t.Errorf("request = %s %s, want DELETE /calendar/habits/42.json", r.Method, r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + }), "habit", "delete", "42") + if err != nil { + t.Fatalf("execute habit delete: %v", err) + } + if response.Summary != "Habit deleted" { + t.Errorf("summary = %q", response.Summary) + } +} + +func TestHabitFlagHelpListsAcceptedIconsAndColors(t *testing.T) { + for _, command := range []*cobra.Command{newHabitCreateCommand().cmd, newHabitEditCommand().cmd} { + if usage := command.Flags().Lookup("icon").Usage; !strings.Contains(usage, habitvalues.IconValues) { + t.Errorf("%s icon help does not list all values: %q", command.Name(), usage) + } + if usage := command.Flags().Lookup("color").Usage; !strings.Contains(usage, habitvalues.ColorValues) { + t.Errorf("%s color help does not list all values: %q", command.Name(), usage) + } + } +} + +func TestHabitMutationValidationMakesNoRequest(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "create name", args: []string{"habit", "create", ""}, want: "name is required"}, + {name: "create icon", args: []string{"habit", "create", "Read every day", "--icon", "walking"}, want: "icon must be one of"}, + {name: "create days", args: []string{"habit", "create", "Read every day", "--days", "funday"}, want: "invalid weekday"}, + {name: "edit ID", args: []string{"habit", "edit", "nope", "--color", "blue"}, want: "invalid habit ID"}, + {name: "edit color", args: []string{"habit", "edit", "42", "--color", "orange"}, want: "color must be one of"}, + {name: "edit changes", args: []string{"habit", "edit", "42"}, want: "provide at least one"}, + {name: "delete ID", args: []string{"habit", "delete", "0"}, want: "invalid habit ID"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var requests atomic.Int32 + _, err := runJSONCommand(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + requests.Add(1) + }), tt.args...) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want %q", err, tt.want) + } + if requests.Load() != 0 { + t.Errorf("requests = %d, want 0", requests.Load()) + } + }) + } +} diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index 5e900e6b..55708e95 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -122,7 +122,7 @@ CALENDAR & TASKS calendars List calendars recordings List events, to-dos, and other calendar entries todo Create and manage to-dos - habit Track completed habits + habit Create and manage habits timetrack Track time journal Read and write journal entries diff --git a/internal/habit/days.go b/internal/habit/days.go new file mode 100644 index 00000000..7b9ea5d8 --- /dev/null +++ b/internal/habit/days.go @@ -0,0 +1,64 @@ +package habit + +import ( + "fmt" + "sort" + "strconv" + "strings" +) + +const ( + DefaultIcon = "weights" + DefaultColor = "blue" +) + +var EveryDay = []int32{0, 1, 2, 3, 4, 5, 6} + +var weekdayNumbers = map[string]int32{ + "sun": 0, "sunday": 0, + "mon": 1, "monday": 1, + "tue": 2, "tues": 2, "tuesday": 2, + "wed": 3, "weds": 3, "wednesday": 3, + "thu": 4, "thur": 4, "thurs": 4, "thursday": 4, + "fri": 5, "friday": 5, + "sat": 6, "saturday": 6, +} + +// ParseDays accepts comma-separated weekday names or numbers from Sunday (0) through Saturday (6). +func ParseDays(value string) ([]int32, error) { + parts := strings.FieldsFunc(strings.TrimSpace(value), func(r rune) bool { + return r == ',' || r == ' ' || r == ';' + }) + if len(parts) == 0 { + return nil, fmt.Errorf("days must include at least one weekday") + } + + seen := make(map[int32]bool, 7) + days := make([]int32, 0, len(parts)) + for _, part := range parts { + key := strings.ToLower(strings.TrimSpace(part)) + day, ok := weekdayNumbers[key] + if !ok { + number, err := strconv.ParseInt(key, 10, 32) + if err != nil || number < 0 || number > 6 { + return nil, fmt.Errorf("invalid weekday %q (use Sunday-Saturday or 0-6)", part) + } + day = int32(number) + } + if !seen[day] { + seen[day] = true + days = append(days, day) + } + } + sort.Slice(days, func(i, j int) bool { return days[i] < days[j] }) + return days, nil +} + +// FormatDays returns weekdays in the numeric form accepted by HEY. +func FormatDays(days []int32) string { + values := make([]string, 0, len(days)) + for _, day := range days { + values = append(values, strconv.FormatInt(int64(day), 10)) + } + return strings.Join(values, ",") +} diff --git a/internal/habit/days_test.go b/internal/habit/days_test.go new file mode 100644 index 00000000..c4065c7e --- /dev/null +++ b/internal/habit/days_test.go @@ -0,0 +1,24 @@ +package habit + +import ( + "reflect" + "testing" +) + +func TestParseDays(t *testing.T) { + days, err := ParseDays("Friday, monday;3 1") + if err != nil { + t.Fatal(err) + } + if want := []int32{1, 3, 5}; !reflect.DeepEqual(days, want) { + t.Errorf("days = %v, want %v", days, want) + } +} + +func TestParseDaysRejectsInvalidValues(t *testing.T) { + for _, value := range []string{"", "7", "weekday"} { + if _, err := ParseDays(value); err == nil { + t.Errorf("ParseDays(%q) succeeded", value) + } + } +} diff --git a/internal/habit/values.go b/internal/habit/values.go new file mode 100644 index 00000000..1ecbd458 --- /dev/null +++ b/internal/habit/values.go @@ -0,0 +1,42 @@ +package habit + +import ( + "fmt" + "strings" +) + +const ( + // IconValues lists the icon names HEY accepts for habits. + IconValues = "weights, art, baseball, basketball, bed, bicycle, brain, camera, cat, church, clean, cook, dog, football, fruit, game, garden, guitar, heart, hydrate, meditate, money, music, piano, pill, plant, read, run, smoke, soccer, study, swim, tea, toothbrush, tree, tv, vegetable, walk, water, write, yoga, heat, ice, lotus, breathe, drink, star" + // ColorValues lists the color names HEY accepts for habits. + ColorValues = "blue, red, gold, green, teal, purple, pink, brown" +) + +var ( + acceptedIcons = acceptedValues(IconValues) + acceptedColors = acceptedValues(ColorValues) +) + +// ValidateIcon accepts an icon name supported by HEY habits. +func ValidateIcon(value string) error { + if !acceptedIcons[value] { + return fmt.Errorf("icon must be one of: %s", IconValues) + } + return nil +} + +// ValidateColor accepts a color name supported by HEY habits. +func ValidateColor(value string) error { + if !acceptedColors[value] { + return fmt.Errorf("color must be one of: %s", ColorValues) + } + return nil +} + +func acceptedValues(values string) map[string]bool { + accepted := make(map[string]bool) + for _, value := range strings.Split(values, ", ") { + accepted[value] = true + } + return accepted +} diff --git a/internal/habit/values_test.go b/internal/habit/values_test.go new file mode 100644 index 00000000..554aa34d --- /dev/null +++ b/internal/habit/values_test.go @@ -0,0 +1,31 @@ +package habit + +import ( + "strings" + "testing" +) + +func TestValidateIconAcceptsEveryIconValue(t *testing.T) { + for _, icon := range strings.Split(IconValues, ", ") { + if err := ValidateIcon(icon); err != nil { + t.Errorf("ValidateIcon(%q) = %v", icon, err) + } + } +} + +func TestValidateColorAcceptsEveryColorValue(t *testing.T) { + for _, color := range strings.Split(ColorValues, ", ") { + if err := ValidateColor(color); err != nil { + t.Errorf("ValidateColor(%q) = %v", color, err) + } + } +} + +func TestHabitValuesRejectUnsupportedNames(t *testing.T) { + if err := ValidateIcon("walking"); err == nil { + t.Error("ValidateIcon accepted walking") + } + if err := ValidateColor("orange"); err == nil { + t.Error("ValidateColor accepted orange") + } +} diff --git a/internal/models/calendar.go b/internal/models/calendar.go index fcab0ecc..b8c1619c 100644 --- a/internal/models/calendar.go +++ b/internal/models/calendar.go @@ -29,6 +29,9 @@ type Recording struct { Type string `json:"type"` CompletedAt string `json:"completed_at,omitempty"` Label string `json:"label,omitempty"` + Icon string `json:"icon,omitempty"` + Color string `json:"color,omitempty"` + Days []int32 `json:"days,omitempty"` Calendar *Calendar `json:"calendar,omitempty"` RemindersLabel string `json:"reminders_label,omitempty"` OccurrencesURL string `json:"occurrences_url,omitempty"` diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index 46d2225b..3a6419ac 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -8,6 +8,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/basecamp/hey-sdk/go/pkg/generated" + hey "github.com/basecamp/hey-sdk/go/pkg/hey" "github.com/basecamp/hey-cli/internal/models" ) @@ -39,6 +40,11 @@ type timeTrackCategorySavedMsg struct { err error } +type habitMutationMsg struct { + action string + err error +} + // --- Calendar section view --- type calendarView struct { @@ -65,7 +71,12 @@ type calendarView struct { inThread bool loading bool - timeTrackCategories *timeTrackCategoryManager + timeTrackCategories *timeTrackCategoryManager + habitForm *habitForm + habitIndex int + habitMutating bool + confirmedHabitDeleteID int64 + notice string } func newCalendarView(vc *viewContext) *calendarView { @@ -79,6 +90,7 @@ func newCalendarView(vc *viewContext) *calendarView { } func (v *calendarView) Init() tea.Cmd { + v.confirmedHabitDeleteID = 0 cmds := []tea.Cmd{v.fetchIdentity()} if len(v.calendars) == 0 { v.loading = true @@ -109,10 +121,34 @@ func (v *calendarView) Update(msg tea.Msg) (tea.Cmd, bool) { case recordingsLoadedMsg: v.loading = false + v.confirmedHabitDeleteID = 0 v.events, v.todos, v.habits = splitRecordings(msg.recordings) + v.normalizeHabitSelection() v.rebuildView() return nil, true + case habitMutationMsg: + v.loading = false + v.habitMutating = false + if msg.err != nil { + if v.habitForm != nil { + v.habitForm.saving = false + v.habitForm.status = "Save failed: " + msg.err.Error() + v.habitForm.isError = true + } else { + v.notice = "Delete failed: " + msg.err.Error() + } + return nil, true + } + v.habitForm = nil + v.confirmedHabitDeleteID = 0 + v.notice = msg.action + if v.calIndex >= 0 && v.calIndex < len(v.calendars) { + v.loading = true + return v.fetchRecordings(v.calendars[v.calIndex].ID), true + } + return nil, true + case recordingDetailMsg: v.loading = false v.inThread = true @@ -147,6 +183,9 @@ func (v *calendarView) Update(msg tea.Msg) (tea.Cmd, bool) { return v.fetchTimeTrackCategories(), true } + if v.habitForm != nil { + return v.habitForm.update(msg), true + } if v.inThread { var cmd tea.Cmd v.topicViewport, cmd = v.topicViewport.Update(msg) @@ -160,23 +199,45 @@ func (v *calendarView) View() string { if v.timeTrackCategories != nil { return v.timeTrackCategories.view(v.vc.styles, v.vc.width, v.vc.height) } + if v.habitForm != nil { + return v.habitForm.view() + } if v.inThread { return v.topicViewport.View() } - return v.contentVP.View() + var heading string + if v.notice != "" { + heading = v.vc.styles.title.Render(v.notice) + "\n" + } + if habit := v.selectedHabit(); habit != nil { + heading += styleMuted.Render(fmt.Sprintf("Selected habit %d/%d: %s (ID %d)", v.habitIndex+1, len(v.manageableHabits()), habit.Title, habit.ID)) + "\n" + } + return heading + v.contentVP.View() } func (v *calendarView) HelpBindings() []helpBinding { if v.timeTrackCategories != nil { return v.timeTrackCategories.helpBindings() } + if v.habitForm != nil { + return v.habitForm.helpBindings() + } if v.inThread { return nil } - return []helpBinding{ - {"v", v.viewMode.next().String() + " view"}, - {"c", "time categories"}, + bindings := []helpBinding{{"v", v.viewMode.next().String() + " view"}, {"c", "time categories"}} + if v.viewingPersonalCalendar() { + bindings = append(bindings, helpBinding{"a", "create habit"}) } + if len(v.manageableHabits()) > 0 { + bindings = append(bindings, helpBinding{"[/]", "select habit"}, helpBinding{"e", "edit habit"}) + deleteLabel := "delete habit" + if v.habitDeleteConfirmed() { + deleteLabel = "confirm delete" + } + bindings = append(bindings, helpBinding{"x", deleteLabel}) + } + return bindings } func (v *calendarView) SubnavItems() ([]navItem, int, string, bool) { @@ -210,12 +271,27 @@ func (v *calendarView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { if v.timeTrackCategories != nil { return v.handleTimeTrackCategoryKey(msg) } + if v.habitForm != nil { + if msg.Key().Code == tea.KeyEscape && !v.habitForm.saving { + v.habitForm = nil + return nil + } + cmd, submit := v.habitForm.handleKey(msg) + if submit { + return v.saveHabit() + } + return cmd + } if v.inThread { var cmd tea.Cmd v.topicViewport, cmd = v.topicViewport.Update(msg) return cmd } + if msg.String() != "x" { + v.confirmedHabitDeleteID = 0 + } + v.notice = "" switch msg.String() { case "c": v.timeTrackCategories = newTimeTrackCategoryManager() @@ -229,6 +305,31 @@ func (v *calendarView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { } v.rebuildView() return nil + case "a": + if !v.viewingPersonalCalendar() { + v.notice = "Habits can only be created from the personal calendar" + return nil + } + return v.startHabitForm(habitFormCreate, models.Recording{}) + case "[": + v.moveHabitSelection(-1) + return nil + case "]": + v.moveHabitSelection(1) + return nil + case "e": + if habit := v.selectedHabit(); habit != nil { + return v.startHabitForm(habitFormEdit, *habit) + } + case "x": + if habit := v.selectedHabit(); habit != nil { + if v.confirmedHabitDeleteID != habit.ID { + v.confirmedHabitDeleteID = habit.ID + v.notice = fmt.Sprintf("Press x again to permanently delete %s and its history", habit.Title) + return nil + } + return v.deleteHabit(*habit) + } } // Delegate scrolling to the content viewport @@ -302,9 +403,11 @@ func (v *calendarView) InThread() bool { return v.inThread } func (v *calendarView) ExitThread() { v.inThread = false } func (v *calendarView) Loading() bool { return v.loading } func (v *calendarView) CapturingInput() bool { - return v.timeTrackCategories != nil + return v.timeTrackCategories != nil || v.habitForm != nil } +func (v *calendarView) AccountSwitchBlocked() bool { return v.habitMutating } + // Restyle re-renders the day/week/year grid, which caches styled output in its // viewport. The recording detail is plain text and needs nothing. func (v *calendarView) Restyle() { @@ -315,9 +418,12 @@ func (v *calendarView) Restyle() { func (v *calendarView) Resize(width, height int) { v.contentVP.SetWidth(width) - v.contentVP.SetHeight(height) + v.contentVP.SetHeight(max(height-2, 1)) v.topicViewport.SetWidth(width) v.topicViewport.SetHeight(height) + if v.habitForm != nil { + v.habitForm.resize(width, height) + } v.rebuildView() } @@ -356,6 +462,91 @@ func (v *calendarView) rebuildView() { } } +func (v *calendarView) viewingPersonalCalendar() bool { + return v.calIndex >= 0 && v.calIndex < len(v.calendars) && v.calendars[v.calIndex].Personal +} + +func (v *calendarView) manageableHabits() []models.Recording { + seen := make(map[int64]bool) + habits := make([]models.Recording, 0, len(v.habits)) + for _, habit := range v.habits { + if habit.ID <= 0 || seen[habit.ID] { + continue + } + seen[habit.ID] = true + habits = append(habits, habit) + } + return habits +} + +func (v *calendarView) selectedHabit() *models.Recording { + habits := v.manageableHabits() + if len(habits) == 0 { + return nil + } + v.habitIndex = max(0, min(v.habitIndex, len(habits)-1)) + habit := habits[v.habitIndex] + return &habit +} + +func (v *calendarView) normalizeHabitSelection() { + habits := v.manageableHabits() + if len(habits) == 0 { + v.habitIndex = 0 + return + } + v.habitIndex = max(0, min(v.habitIndex, len(habits)-1)) +} + +func (v *calendarView) moveHabitSelection(delta int) { + habits := v.manageableHabits() + if len(habits) == 0 { + v.habitIndex = 0 + return + } + v.habitIndex = (v.habitIndex + delta + len(habits)) % len(habits) +} + +func (v *calendarView) habitDeleteConfirmed() bool { + habit := v.selectedHabit() + return habit != nil && v.confirmedHabitDeleteID == habit.ID +} + +func (v *calendarView) startHabitForm(mode habitFormMode, recording models.Recording) tea.Cmd { + v.confirmedHabitDeleteID = 0 + v.habitForm = newHabitForm(mode, recording, v.vc.styles) + v.habitForm.resize(v.vc.width, v.vc.height) + return v.habitForm.init() +} + +func (v *calendarView) saveHabit() tea.Cmd { + form := v.habitForm + name, icon, color, days, _ := form.values() + params := hey.HabitParams{Name: name, Icon: icon, Color: color, Days: days} + v.habitMutating = true + v.loading = true + return func() tea.Msg { + var err error + action := "Habit created" + if form.mode == habitFormCreate { + _, err = v.vc.sdk.Habits().Create(v.vc.ctx, params) + } else { + action = "Habit updated" + _, err = v.vc.sdk.Habits().Update(v.vc.ctx, form.habitID, params) + } + return habitMutationMsg{action: action, err: err} + } +} + +func (v *calendarView) deleteHabit(recording models.Recording) tea.Cmd { + v.habitMutating = true + v.loading = true + return func() tea.Msg { + err := v.vc.sdk.Habits().Delete(v.vc.ctx, recording.ID) + return habitMutationMsg{action: "Habit deleted", err: err} + } +} + // --- SDK type converters --- func sdkCalendarToModel(c generated.Calendar) models.Calendar { @@ -373,6 +564,7 @@ func sdkRecordingToModel(r generated.Recording) models.Recording { CreatedAt: formatTimestamp(r.CreatedAt), UpdatedAt: formatTimestamp(r.UpdatedAt), Type: r.Type, Content: r.Content, RemindersLabel: r.RemindersLabel, CompletedAt: formatTimestamp(r.CompletedAt), Label: r.Label, + Icon: r.Icon, Color: r.Color, Days: append([]int32(nil), r.Days...), } } diff --git a/internal/tui/calendar_test.go b/internal/tui/calendar_test.go index 84ce748d..23d12cee 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -10,7 +10,7 @@ import ( func testCalendars() []models.Calendar { return []models.Calendar{ {ID: 10, Name: "Work", Kind: "owned"}, - {ID: 11, Name: "Personal", Kind: "personal"}, + {ID: 11, Name: "Personal", Kind: "personal", Personal: true}, } } @@ -213,14 +213,18 @@ func TestCalendarViewInThread(t *testing.T) { func TestCalendarViewHelpBindingsShowsViewToggle(t *testing.T) { v := calendarWithRecordings() + v.calIndex = 1 bindings := v.HelpBindings() - if len(bindings) != 2 { - t.Fatalf("expected 2 bindings, got %d", len(bindings)) - } - if bindings[0].key != "v" { - t.Errorf("binding key = %q, want \"v\"", bindings[0].key) - } - if bindings[1].key != "c" { - t.Errorf("binding key = %q, want \"c\"", bindings[1].key) + if len(bindings) != 6 { + t.Fatalf("expected 6 bindings, got %d", len(bindings)) + } + for _, want := range []string{"v", "c", "a", "[/]", "e", "x"} { + found := false + for _, binding := range bindings { + found = found || binding.key == want + } + if !found { + t.Errorf("missing binding %q: %+v", want, bindings) + } } } diff --git a/internal/tui/habit_form.go b/internal/tui/habit_form.go new file mode 100644 index 00000000..dda70a9d --- /dev/null +++ b/internal/tui/habit_form.go @@ -0,0 +1,175 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + habitvalues "github.com/basecamp/hey-cli/internal/habit" + "github.com/basecamp/hey-cli/internal/models" +) + +type habitFormMode int + +const ( + habitFormCreate habitFormMode = iota + habitFormEdit +) + +const ( + habitFieldName = iota + habitFieldIcon + habitFieldColor + habitFieldDays +) + +type habitForm struct { + mode habitFormMode + habitID int64 + inputs []textinput.Model + focus int + status string + isError bool + saving bool + width int + styles styles +} + +func newHabitForm(mode habitFormMode, recording models.Recording, styles styles) *habitForm { + form := &habitForm{mode: mode, habitID: recording.ID, styles: styles} + placeholders := []string{"Morning strength training", habitvalues.DefaultIcon, habitvalues.DefaultColor, "monday,wednesday,friday"} + for _, placeholder := range placeholders { + input := textinput.New() + input.Prompt = "" + input.Placeholder = placeholder + form.inputs = append(form.inputs, input) + } + if mode == habitFormCreate { + form.inputs[habitFieldIcon].SetValue(habitvalues.DefaultIcon) + form.inputs[habitFieldColor].SetValue(habitvalues.DefaultColor) + form.inputs[habitFieldDays].SetValue(habitvalues.FormatDays(habitvalues.EveryDay)) + } else { + form.inputs[habitFieldName].SetValue(recording.Title) + form.inputs[habitFieldIcon].SetValue(recording.Icon) + form.inputs[habitFieldColor].SetValue(recording.Color) + form.inputs[habitFieldDays].SetValue(habitvalues.FormatDays(recording.Days)) + } + return form +} + +func (f *habitForm) init() tea.Cmd { return f.focusCurrent() } + +func (f *habitForm) focusCurrent() tea.Cmd { + for i := range f.inputs { + f.inputs[i].Blur() + } + return f.inputs[f.focus].Focus() +} + +func (f *habitForm) resize(width, _ int) { + f.width = width + for i := range f.inputs { + f.inputs[i].SetWidth(max(width-12, 10)) + } +} + +func (f *habitForm) values() (name, icon, color string, days []int32, err error) { + name = strings.TrimSpace(f.inputs[habitFieldName].Value()) + icon = strings.TrimSpace(f.inputs[habitFieldIcon].Value()) + color = strings.TrimSpace(f.inputs[habitFieldColor].Value()) + days, err = habitvalues.ParseDays(f.inputs[habitFieldDays].Value()) + return +} + +func (f *habitForm) validate() string { + name, icon, color, _, err := f.values() + if name == "" { + return "Name is required" + } + if icon == "" { + return "Icon is required" + } + if problem := habitvalues.ValidateIcon(icon); problem != nil { + return problem.Error() + } + if color == "" { + return "Color is required" + } + if problem := habitvalues.ValidateColor(color); problem != nil { + return problem.Error() + } + if err != nil { + return err.Error() + } + return "" +} + +func (f *habitForm) handleKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { + if f.saving { + return nil, false + } + switch { + case msg.Key().Code == tea.KeyTab && msg.Key().Mod == tea.ModShift: + f.focus = (f.focus + len(f.inputs) - 1) % len(f.inputs) + return f.focusCurrent(), false + case msg.Key().Code == tea.KeyTab || msg.Key().Code == tea.KeyEnter: + f.focus = (f.focus + 1) % len(f.inputs) + return f.focusCurrent(), false + case msg.String() == "ctrl+s": + if problem := f.validate(); problem != "" { + f.status = problem + f.isError = true + return nil, false + } + f.saving = true + f.status = "Saving…" + f.isError = false + return nil, true + } + return f.update(msg), false +} + +func (f *habitForm) update(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + f.inputs[f.focus], cmd = f.inputs[f.focus].Update(msg) + return cmd +} + +func (f *habitForm) helpBindings() []helpBinding { + return []helpBinding{{"tab", "next field"}, {"ctrl+s", "save"}, {"esc", "cancel"}} +} + +func (f *habitForm) view() string { + title := "Create habit" + if f.mode == habitFormEdit { + title = "Edit habit" + } + labels := []string{"Name", "Icon", "Color", "Days"} + var b strings.Builder + b.WriteString(f.styles.title.Render(title)) + b.WriteString("\n\n") + for i := range f.inputs { + fmt.Fprintf(&b, "%s %s\n", styleMuted.Render(fmt.Sprintf("%8s:", labels[i])), f.inputs[i].View()) + } + guidance := []string{ + "Icons: " + habitvalues.IconValues, + "Colors: " + habitvalues.ColorValues, + "Days accept weekday names or 0 (Sunday) through 6 (Saturday).", + } + for _, text := range guidance { + for _, line := range wrapText(text, max(f.width, 20)) { + b.WriteString(styleMuted.Render(line) + "\n") + } + } + if f.status != "" { + statusStyle := styleMuted + if f.isError { + statusStyle = lipgloss.NewStyle().Foreground(colorError) + } + b.WriteString("\n\n" + statusStyle.Render(f.status)) + } + return b.String() +} diff --git a/internal/tui/habit_form_test.go b/internal/tui/habit_form_test.go new file mode 100644 index 00000000..7df6ba0a --- /dev/null +++ b/internal/tui/habit_form_test.go @@ -0,0 +1,350 @@ +package tui + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + tea "charm.land/bubbletea/v2" + + hey "github.com/basecamp/hey-sdk/go/pkg/hey" + + habitvalues "github.com/basecamp/hey-cli/internal/habit" + "github.com/basecamp/hey-cli/internal/models" +) + +func TestHabitFormValidationAndKeyRouting(t *testing.T) { + view := newCalendarView(testVC()) + view.calendars = []models.Calendar{{ID: 10, Name: "Rob Zolkos", Personal: true}} + view.Resize(80, 30) + if cmd := view.HandleContentKey(keyPress("a")); cmd == nil || view.habitForm == nil || !view.CapturingInput() { + t.Fatal("a should open and focus the habit form") + } + view.HandleContentKey(keyPress("ctrl+s")) + if view.habitForm.status != "Name is required" || view.habitForm.saving { + t.Errorf("empty save status = %q, saving=%v", view.habitForm.status, view.habitForm.saving) + } + view.HandleContentKey(keyPress("R")) + if got := view.habitForm.inputs[habitFieldName].Value(); got != "R" { + t.Errorf("form key was not routed to name input: %q", got) + } + view.habitForm.inputs[habitFieldName].SetValue("Read before bed") + view.habitForm.inputs[habitFieldIcon].SetValue("walking") + view.HandleContentKey(keyPress("ctrl+s")) + if !strings.Contains(view.habitForm.status, "icon must be one of") { + t.Errorf("invalid icon status = %q", view.habitForm.status) + } + view.habitForm.inputs[habitFieldIcon].SetValue("read") + view.habitForm.inputs[habitFieldColor].SetValue("orange") + view.HandleContentKey(keyPress("ctrl+s")) + if !strings.Contains(view.habitForm.status, "color must be one of") { + t.Errorf("invalid color status = %q", view.habitForm.status) + } + view.habitForm.inputs[habitFieldColor].SetValue("blue") + view.habitForm.inputs[habitFieldDays].SetValue("Monday, someday") + view.HandleContentKey(keyPress("ctrl+s")) + if !strings.Contains(view.habitForm.status, "invalid weekday") { + t.Errorf("invalid days status = %q", view.habitForm.status) + } + view.HandleContentKey(keyPress("esc")) + if view.habitForm != nil || view.CapturingInput() { + t.Error("escape should close a form that is not saving") + } +} + +func TestHabitFormGuidanceListsAcceptedIconsAndColors(t *testing.T) { + form := newHabitForm(habitFormCreate, models.Recording{}, testVC().styles) + form.resize(50, 30) + rendered := form.view() + for _, value := range strings.Split(habitvalues.IconValues, ", ") { + if !strings.Contains(rendered, value) { + t.Errorf("form guidance is missing icon %q", value) + } + } + for _, value := range strings.Split(habitvalues.ColorValues, ", ") { + if !strings.Contains(rendered, value) { + t.Errorf("form guidance is missing color %q", value) + } + } +} + +func TestCalendarHabitCreateRequiresPersonalCalendarMetadata(t *testing.T) { + view := newCalendarView(testVC()) + view.calendars = []models.Calendar{{ID: 10, Name: "Personal", Personal: false}} + + for _, binding := range view.HelpBindings() { + if binding.key == "a" { + t.Errorf("non-personal calendar offers create: %v", view.HelpBindings()) + } + } + if cmd := view.HandleContentKey(keyPress("a")); cmd != nil || view.habitForm != nil { + t.Fatalf("non-personal create = cmd:%v form:%v", cmd, view.habitForm) + } + if view.notice != "Habits can only be created from the personal calendar" { + t.Errorf("notice = %q", view.notice) + } +} + +func TestCalendarHabitSelectionAndEditPrefill(t *testing.T) { + view := newCalendarView(testVC()) + view.habits = []models.Recording{ + {ID: 7, Title: "Read before bed", Icon: "read", Color: "blue", Days: []int32{1, 3, 5}}, + {ID: 8, Title: "Evening walk", Icon: "walk", Color: "green", Days: []int32{0, 6}}, + {ID: 7, Title: "Read before bed"}, + } + if selected := view.selectedHabit(); selected == nil || selected.ID != 7 { + t.Fatalf("initial selection = %+v", selected) + } + view.HandleContentKey(keyPress("]")) + if selected := view.selectedHabit(); selected == nil || selected.ID != 8 { + t.Fatalf("next selection = %+v", selected) + } + view.HandleContentKey(keyPress("e")) + if view.habitForm == nil || view.habitForm.mode != habitFormEdit || view.habitForm.habitID != 8 { + t.Fatal("e should edit the selected visible habit") + } + if got := view.habitForm.inputs[habitFieldName].Value(); got != "Evening walk" { + t.Errorf("prefilled name = %q", got) + } + if got := view.habitForm.inputs[habitFieldDays].Value(); got != "0,6" { + t.Errorf("prefilled days = %q", got) + } +} + +type recordedHabitRequests struct { + mu sync.Mutex + requests []string + bodies []string +} + +func (r *recordedHabitRequests) add(req *http.Request) { + body, _ := io.ReadAll(req.Body) + r.mu.Lock() + defer r.mu.Unlock() + r.requests = append(r.requests, req.Method+" "+req.URL.Path) + r.bodies = append(r.bodies, strings.TrimSpace(string(body))) +} + +func (r *recordedHabitRequests) snapshot() ([]string, []string) { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.requests...), append([]string(nil), r.bodies...) +} + +func calendarHabitsWithServer(t *testing.T) (*calendarView, *recordedHabitRequests) { + t.Helper() + recorded := &recordedHabitRequests{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + recorded.add(req) + w.Header().Set("Content-Type", "application/json") + switch { + case req.Method == http.MethodPost && req.URL.Path == "/calendar/habits.json": + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"id":9,"title":"Practice piano","type":"CalendarHabit","icon":"music","color":"green","days":[1,3,5]}`) + case req.Method == http.MethodPatch && req.URL.Path == "/calendar/habits/7.json": + _, _ = io.WriteString(w, `{"id":7,"title":"Read every evening","type":"CalendarHabit","icon":"read","color":"purple","days":[0,6]}`) + case req.Method == http.MethodDelete && req.URL.Path == "/calendar/habits/7.json": + w.WriteHeader(http.StatusNoContent) + case req.Method == http.MethodGet && req.URL.Path == "/calendars/10/recordings.json": + _, _ = io.WriteString(w, `{"Calendar::Habit":[{"id":7,"title":"Read before bed","type":"CalendarHabit","icon":"read","color":"blue","days":[1,3,5]}]}`) + default: + http.NotFound(w, req) + } + })) + t.Cleanup(server.Close) + + client := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "test-token"}, hey.WithMaxRetries(0)) + vc := testVC() + vc.sdk = client + view := newCalendarView(vc) + view.calendars = []models.Calendar{{ID: 10, Name: "Rob Zolkos", Personal: true}} + view.habits = []models.Recording{{ID: 7, Title: "Read before bed", Icon: "read", Color: "blue", Days: []int32{1, 3, 5}}} + view.Resize(vc.width, vc.height) + return view, recorded +} + +func calendarHabitsWithFailingServer(t *testing.T, status int) *calendarView { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, `{"error":"habit mutation failed"}`) + })) + t.Cleanup(server.Close) + + client := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "test-token"}, hey.WithMaxRetries(0)) + vc := testVC() + vc.sdk = client + view := newCalendarView(vc) + view.calendars = []models.Calendar{{ID: 10, Name: "Rob Zolkos", Personal: true}} + view.habits = []models.Recording{{ID: 7, Title: "Read before bed", Icon: "read", Color: "blue", Days: []int32{1, 3, 5}}} + view.Resize(vc.width, vc.height) + return view +} + +func finishHabitMutation(t *testing.T, view *calendarView, cmd tea.Cmd) { + t.Helper() + msg := cmd() + mutation, ok := msg.(habitMutationMsg) + if !ok { + t.Fatalf("mutation command returned %T", msg) + } + refresh, consumed := view.Update(mutation) + if !consumed || refresh == nil { + t.Fatalf("mutation update = consumed:%v refresh:%v", consumed, refresh) + } + view.Update(refresh()) + if view.loading || view.habitMutating { + t.Errorf("mutation did not finish: loading=%v mutating=%v", view.loading, view.habitMutating) + } +} + +func TestCalendarHabitCreateMutationAndRefresh(t *testing.T) { + view, recorded := calendarHabitsWithServer(t) + view.HandleContentKey(keyPress("a")) + view.habitForm.inputs[habitFieldName].SetValue("Practice piano") + view.habitForm.inputs[habitFieldIcon].SetValue("music") + view.habitForm.inputs[habitFieldColor].SetValue("green") + view.habitForm.inputs[habitFieldDays].SetValue("Mon,Wed,Fri") + cmd := view.HandleContentKey(keyPress("ctrl+s")) + if cmd == nil || !view.habitMutating { + t.Fatal("ctrl+s should start habit creation") + } + finishHabitMutation(t, view, cmd) + if view.notice != "Habit created" || view.habitForm != nil { + t.Errorf("create state = notice:%q form:%v", view.notice, view.habitForm) + } + requests, bodies := recorded.snapshot() + if len(requests) < 2 || requests[0] != "POST /calendar/habits.json" || requests[1] != "GET /calendars/10/recordings.json" { + t.Errorf("requests = %v", requests) + } + var payload map[string]map[string]any + if err := json.Unmarshal([]byte(bodies[0]), &payload); err != nil { + t.Fatal(err) + } + if payload["calendar_habit"]["name"] != "Practice piano" { + t.Errorf("create payload = %v", payload) + } +} + +func TestCalendarHabitEditMutationAndRefresh(t *testing.T) { + view, recorded := calendarHabitsWithServer(t) + view.HandleContentKey(keyPress("e")) + view.habitForm.inputs[habitFieldName].SetValue("Read every evening") + view.habitForm.inputs[habitFieldColor].SetValue("purple") + view.habitForm.inputs[habitFieldDays].SetValue("0,6") + cmd := view.HandleContentKey(keyPress("ctrl+s")) + finishHabitMutation(t, view, cmd) + if view.notice != "Habit updated" { + t.Errorf("notice = %q", view.notice) + } + requests, _ := recorded.snapshot() + if len(requests) < 2 || requests[0] != "PATCH /calendar/habits/7.json" || requests[1] != "GET /calendars/10/recordings.json" { + t.Errorf("requests = %v", requests) + } +} + +func TestCalendarHabitSaveFailuresUnlockAndPreserveFormValues(t *testing.T) { + tests := []struct { + name string + status int + open func(*calendarView) + }{ + {name: "create 422", status: http.StatusUnprocessableEntity, open: func(view *calendarView) { view.HandleContentKey(keyPress("a")) }}, + {name: "edit 500", status: http.StatusInternalServerError, open: func(view *calendarView) { view.HandleContentKey(keyPress("e")) }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + view := calendarHabitsWithFailingServer(t, tt.status) + tt.open(view) + values := []string{"Practice piano", "piano", "gold", "Mon,Wed,Fri"} + for i, value := range values { + view.habitForm.inputs[i].SetValue(value) + } + + cmd := view.HandleContentKey(keyPress("ctrl+s")) + if cmd == nil { + t.Fatal("save did not return a mutation command") + } + refresh, consumed := view.Update(cmd()) + if !consumed || refresh != nil { + t.Fatalf("failed mutation update = consumed:%v refresh:%v", consumed, refresh) + } + if view.habitForm == nil || view.habitForm.saving || view.habitMutating || view.loading { + t.Fatalf("failed save state = form:%v saving:%v mutating:%v loading:%v", view.habitForm, view.habitForm != nil && view.habitForm.saving, view.habitMutating, view.loading) + } + if !strings.Contains(view.habitForm.status, "Save failed") { + t.Errorf("status = %q", view.habitForm.status) + } + for i, want := range values { + if got := view.habitForm.inputs[i].Value(); got != want { + t.Errorf("field %d after failure = %q, want %q", i, got, want) + } + } + }) + } +} + +func TestCalendarHabitDeleteFailurePreservesConfirmationAndSelection(t *testing.T) { + view := calendarHabitsWithFailingServer(t, http.StatusUnprocessableEntity) + selected := view.selectedHabit() + view.HandleContentKey(keyPress("x")) + cmd := view.HandleContentKey(keyPress("x")) + if cmd == nil { + t.Fatal("confirmed delete did not return a mutation command") + } + refresh, consumed := view.Update(cmd()) + if !consumed || refresh != nil { + t.Fatalf("failed delete update = consumed:%v refresh:%v", consumed, refresh) + } + if !view.habitDeleteConfirmed() || view.habitMutating || view.loading { + t.Errorf("failed delete state = confirmed ID:%d mutating:%v loading:%v", view.confirmedHabitDeleteID, view.habitMutating, view.loading) + } + if current := view.selectedHabit(); current == nil || selected == nil || current.ID != selected.ID || view.habitIndex != 0 { + t.Errorf("selection changed after delete failure: before=%+v after=%+v index=%d", selected, current, view.habitIndex) + } + if !strings.Contains(view.notice, "Delete failed") { + t.Errorf("notice = %q", view.notice) + } +} + +func TestCalendarHabitDeleteConfirmationIsBoundToSelectedHabit(t *testing.T) { + view, _ := calendarHabitsWithServer(t) + view.HandleContentKey(keyPress("x")) + if view.confirmedHabitDeleteID != 7 { + t.Fatalf("confirmed habit ID = %d, want 7", view.confirmedHabitDeleteID) + } + + view.Update(recordingsLoadedMsg{recordings: []models.Recording{{ + ID: 8, Title: "Evening walk", Type: "CalendarHabit", Icon: "walk", Color: "gold", Days: []int32{1, 3, 5}, + }}}) + if view.confirmedHabitDeleteID != 0 { + t.Fatalf("recordings reload preserved confirmed habit ID %d", view.confirmedHabitDeleteID) + } + if cmd := view.HandleContentKey(keyPress("x")); cmd != nil || view.confirmedHabitDeleteID != 8 { + t.Fatalf("first x for reloaded habit = cmd:%v confirmed ID:%d", cmd, view.confirmedHabitDeleteID) + } +} + +func TestCalendarHabitDeleteRequiresConfirmationAndRefresh(t *testing.T) { + view, recorded := calendarHabitsWithServer(t) + if cmd := view.HandleContentKey(keyPress("x")); cmd != nil || !view.habitDeleteConfirmed() || !strings.Contains(view.notice, "Press x again") { + t.Fatalf("first x = cmd:%v confirmed ID:%d notice:%q", cmd, view.confirmedHabitDeleteID, view.notice) + } + cmd := view.HandleContentKey(keyPress("x")) + if cmd == nil || !view.habitMutating { + t.Fatal("second x should start deletion") + } + finishHabitMutation(t, view, cmd) + if view.notice != "Habit deleted" || view.confirmedHabitDeleteID != 0 { + t.Errorf("delete state = notice:%q confirmed ID:%d", view.notice, view.confirmedHabitDeleteID) + } + requests, _ := recorded.snapshot() + if len(requests) < 2 || requests[0] != "DELETE /calendar/habits/7.json" || requests[1] != "GET /calendars/10/recordings.json" { + t.Errorf("requests = %v", requests) + } +} diff --git a/internal/tui/translate_test.go b/internal/tui/translate_test.go index d66b4f56..32d02dd4 100644 --- a/internal/tui/translate_test.go +++ b/internal/tui/translate_test.go @@ -121,11 +121,11 @@ func TestSDKRecordingToModel(t *testing.T) { Id: 99, Title: "Standup", AllDay: false, Recurring: true, StartsAt: starts, EndsAt: ends, StartsAtTimeZone: "UTC", EndsAtTimeZone: "UTC", - Type: "Calendar::Event", Content: "notes", RemindersLabel: "10 minutes before", - CompletedAt: done, Label: "work", + Type: "Calendar::Habit", Content: "notes", RemindersLabel: "10 minutes before", + CompletedAt: done, Label: "work", Icon: "read", Color: "blue", Days: []int32{1, 3, 5}, }) - if got.ID != 99 || got.Title != "Standup" || got.Type != "Calendar::Event" { + if got.ID != 99 || got.Title != "Standup" || got.Type != "Calendar::Habit" { t.Errorf("recording = %+v", got) } if got.StartsAt != "2026-08-18T14:00:00Z" || got.EndsAt != "2026-08-18T15:00:00Z" { @@ -140,6 +140,9 @@ func TestSDKRecordingToModel(t *testing.T) { if got.Content != "notes" || got.RemindersLabel != "10 minutes before" || got.Label != "work" { t.Errorf("detail = %+v", got) } + if got.Icon != "read" || got.Color != "blue" || len(got.Days) != 3 || got.Days[1] != 3 { + t.Errorf("habit fields = %+v", got) + } } // An incomplete recording is the common case — a todo has no end, an open one no diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 4aeccbff..74eb4a79 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -167,6 +167,9 @@ hey boxes --quiet --jq '.[].name' | Mark email threads as spam | `hey spam 12345` | | Ignore email threads | `hey ignore 12345` | | Stop ignoring email threads | `hey stop-ignoring 12345` | +| Create habit | `hey habit create "Morning strength training"` | +| Edit habit | `hey habit edit 123 --days mon,wed,fri` | +| Delete habit | `hey habit delete 123` | | Complete habit | `hey habit complete 123` | | Uncomplete habit | `hey habit uncomplete 123` | | Start time tracking | `hey timetrack start` | @@ -430,12 +433,17 @@ hey todo delete 123 # Delete a todo ### Habits ```bash +hey habit create "Morning strength training" # Create with weights, blue, every day +hey habit create "Practice piano" --icon music --color green --days mon,wed,fri +hey habit edit 123 --name "Evening walk" # Omitted fields remain unchanged +hey habit edit 123 --days 0,6 # Sunday and Saturday +hey habit delete 123 # Permanently delete habit and history hey habit complete 123 # Mark habit complete for today hey habit complete 123 --date 2024-01-15 # Mark complete for specific date hey habit uncomplete 123 # Unmark habit for today ``` -Habit IDs can be found via `hey recordings --json`. +Habit IDs can be found via `hey recordings --json`. Days accept full weekday names, common abbreviations, or `0` (Sunday) through `6` (Saturday). ### Time Tracking diff --git a/tests/smoke/habit_test.go b/tests/smoke/habit_test.go index cc0fb32b..32691d62 100644 --- a/tests/smoke/habit_test.go +++ b/tests/smoke/habit_test.go @@ -1,124 +1,73 @@ package smoke_test import ( - "bytes" "encoding/json" "fmt" - "io" - "net/http" - "regexp" "strconv" "testing" - "time" ) -// createTestHabit creates a habit via the Rails API using the session cookie. -// Returns the habit ID from the JSON response. func createTestHabit(t *testing.T, name string) int { t.Helper() - body, _ := json.Marshal(map[string]any{ - "calendar_habit": map[string]any{ - "name": name, - "icon": "star", - "color": "blue", - "days": []int{1, 2, 3, 4, 5, 6, 7}, - }, - }) - - url := baseURL + "/calendar/habits" - req, err := http.NewRequest("POST", url, bytes.NewReader(body)) - if err != nil { - t.Fatalf("could not create request: %v", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "*/*") - req.AddCookie(&http.Cookie{Name: "session_token", Value: sessionCookie}) - - // Don't follow redirects — just capture the response status. - client := &http.Client{ - Timeout: 10 * time.Second, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - resp, err := client.Do(req) - if err != nil { - t.Fatalf("habit create request failed: %v", err) - } - resp.Body.Close() - - // 2xx or 3xx (redirect) means success. - if resp.StatusCode >= 400 { - t.Fatalf("habit create returned HTTP %d", resp.StatusCode) - } - - // Find the habit ID by scraping the habits index HTML page. - // The recordings endpoint has in_window scoping that may exclude new habits. - req2, err := http.NewRequest("GET", baseURL+"/calendar/habits", nil) - if err != nil { - t.Fatalf("could not create GET request: %v", err) - } - req2.Header.Set("Accept", "text/html") - req2.AddCookie(&http.Cookie{Name: "session_token", Value: sessionCookie}) - - resp2, err := client.Do(req2) - if err != nil { - t.Fatalf("GET habits failed: %v", err) - } - htmlBody, _ := io.ReadAll(resp2.Body) - resp2.Body.Close() - - // Look for - re := regexp.MustCompile(`title="` + regexp.QuoteMeta(name) + `"[^>]*href="/calendar/habits/(\d+)"`) - m := re.FindSubmatch(htmlBody) - if m == nil { - // Try the reverse order: href before title - re2 := regexp.MustCompile(`href="/calendar/habits/(\d+)"[^>]*title="` + regexp.QuoteMeta(name) + `"`) - m = re2.FindSubmatch(htmlBody) + stdout, stderr, code := hey(t, "habit", "create", name, "--icon", "star", "--json") + if code != 0 { + t.Fatalf("habit create failed (exit %d): %s", code, stderr) } - if m == nil { - t.Fatalf("created habit %q not found in habits index HTML", name) + var response Response + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + t.Fatalf("failed to parse habit create response: %v", err) } - habitID, err := strconv.Atoi(string(m[1])) - if err != nil { - t.Fatalf("could not parse habit ID %q: %v", m[1], err) + id := extractIDFromMap(t, dataAs[map[string]any](t, response)) + habitID, err := strconv.Atoi(id) + if err != nil || habitID <= 0 { + t.Fatalf("could not parse created habit ID %q: %v", id, err) } return habitID } -// deleteTestHabit deletes a habit via the Rails API. func deleteTestHabit(t *testing.T, habitID int) { t.Helper() + _, _, _ = hey(t, "habit", "delete", intStr(habitID)) +} - url := fmt.Sprintf("%s/calendar/habits/%d", baseURL, habitID) - req, err := http.NewRequest("DELETE", url, nil) - if err != nil { - return - } - req.Header.Set("Accept", "*/*") - req.AddCookie(&http.Cookie{Name: "session_token", Value: sessionCookie}) +func TestHabitCreateEditDelete(t *testing.T) { + uid := uniqueID() + name := fmt.Sprintf("Morning stretches %s", uid) + habitID := createTestHabit(t, name) + deleted := false + t.Cleanup(func() { + if !deleted { + deleteTestHabit(t, habitID) + } + }) - client := &http.Client{ - Timeout: 10 * time.Second, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, + updatedName := fmt.Sprintf("Evening stretches %s", uid) + stdout := heyOK(t, "habit", "edit", intStr(habitID), "--name", updatedName, "--color", "green", "--days", "mon,wed,fri", "--json") + var response Response + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + t.Fatalf("failed to parse habit edit response: %v", err) } - resp, err := client.Do(req) - if err != nil { - return + assertContains(t, response.Summary, "updated") + data := dataAs[map[string]any](t, response) + if data["title"] != updatedName { + t.Errorf("updated title = %v, want %q", data["title"], updatedName) + } + + stdout = heyOK(t, "habit", "delete", intStr(habitID), "--json") + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + t.Fatalf("failed to parse habit delete response: %v", err) } - resp.Body.Close() + assertContains(t, response.Summary, "deleted") + deleted = true } func TestHabitComplete(t *testing.T) { uid := uniqueID() - name := fmt.Sprintf("Test habit %s", uid) + name := fmt.Sprintf("Morning reading %s", uid) habitID := createTestHabit(t, name) t.Cleanup(func() { deleteTestHabit(t, habitID) }) - // Complete. stdout := heyOK(t, "habit", "complete", intStr(habitID), "--json") var resp Response if err := json.Unmarshal([]byte(stdout), &resp); err != nil { @@ -126,11 +75,9 @@ func TestHabitComplete(t *testing.T) { } assertContains(t, resp.Summary, "completed") - // Cross-verify: the habit should appear on the habits page as completed. html := fetchHTML(t, baseURL+"/calendar/habits") assertContains(t, html, name) - // Uncomplete. stdout = heyOK(t, "habit", "uncomplete", intStr(habitID), "--json") if err := json.Unmarshal([]byte(stdout), &resp); err != nil { t.Fatalf("failed to parse response: %v", err) @@ -140,7 +87,7 @@ func TestHabitComplete(t *testing.T) { func TestHabitCompleteWithDate(t *testing.T) { uid := uniqueID() - name := fmt.Sprintf("Test habit date %s", uid) + name := fmt.Sprintf("Dated reading habit %s", uid) habitID := createTestHabit(t, name) t.Cleanup(func() { deleteTestHabit(t, habitID) }) @@ -151,10 +98,8 @@ func TestHabitCompleteWithDate(t *testing.T) { } assertContains(t, resp.Summary, "completed") - // Cross-verify: the habit should appear on the habits page. html := fetchHTML(t, baseURL+"/calendar/habits") assertContains(t, html, name) - // Clean up completion. hey(t, "habit", "uncomplete", intStr(habitID), "--date", "2099-06-15") }