diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-25 18:18:59 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-25 18:18:59 +0300 |
| commit | bca7d87f11a3ee88c75dc1ed706009d63022ef98 (patch) | |
| tree | 70b5bed95f11bc30968917d8c088fa8b7697cfac /internal | |
| parent | b642f71049ba6030e45c9ddead6d1cc2b7e289e2 (diff) | |
fr0 add recurring series recurrence edit
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/task/crud.go | 66 | ||||
| -rw-r--r-- | internal/task/task_test.go | 95 | ||||
| -rw-r--r-- | internal/task/taskwarrior.go | 6 | ||||
| -rw-r--r-- | internal/ui/detail_handlers.go | 2 | ||||
| -rw-r--r-- | internal/ui/handlers.go | 12 | ||||
| -rw-r--r-- | internal/ui/keyactions.go | 38 | ||||
| -rw-r--r-- | internal/ui/keyhandlers.go | 74 | ||||
| -rw-r--r-- | internal/ui/table.go | 6 | ||||
| -rw-r--r-- | internal/ui/table_test.go | 99 | ||||
| -rw-r--r-- | internal/ui/ultra.go | 9 |
10 files changed, 364 insertions, 43 deletions
diff --git a/internal/task/crud.go b/internal/task/crud.go index 51dd13c..d0ec612 100644 --- a/internal/task/crud.go +++ b/internal/task/crud.go @@ -255,6 +255,72 @@ func SetRecurrenceContext(ctx context.Context, id int, rec string) error { return modifyTaskContext(ctx, id, "recur:"+rec) } +// SetRecurringSeriesRecurrenceContext sets the recurrence for every known task +// in a recurring series identified by rootUUID. +func SetRecurringSeriesRecurrenceContext(ctx context.Context, rootUUID, rec string) error { + tasks, err := RecurringSeries(ctx, rootUUID) + if err != nil { + return err + } + tasks = recurringSeriesUpdateOrder(tasks, rootUUID) + if len(tasks) == 0 { + return fmt.Errorf("recurring series %s not found", rootUUID) + } + + completed := make([]Task, 0, len(tasks)) + for _, tsk := range tasks { + if tsk.UUID == "" { + continue + } + if err := setRecurrenceUUIDContext(ctx, tsk.UUID, rec); err != nil { + if rollbackErr := restoreRecurringSeriesRecurrences(completed); rollbackErr != nil { + return fmt.Errorf("set recurrence for %s: %w; rollback failed: %w", tsk.UUID, err, rollbackErr) + } + return fmt.Errorf("set recurrence for %s: %w", tsk.UUID, err) + } + completed = append(completed, tsk) + } + if len(completed) == 0 { + return fmt.Errorf("recurring series %s has no task UUIDs", rootUUID) + } + return nil +} + +func recurringSeriesUpdateOrder(tasks []Task, rootUUID string) []Task { + ordered := make([]Task, 0, len(tasks)) + var root []Task + for _, tsk := range tasks { + if tsk.UUID == "" { + continue + } + if tsk.UUID == rootUUID { + root = append(root, tsk) + continue + } + ordered = append(ordered, tsk) + } + return append(ordered, root...) +} + +func restoreRecurringSeriesRecurrences(tasks []Task) error { + ctx, cancel := rollbackContext() + defer cancel() + + for i := len(tasks) - 1; i >= 0; i-- { + if err := setRecurrenceUUIDContext(ctx, tasks[i].UUID, tasks[i].Recur); err != nil { + return fmt.Errorf("restore recurrence for %s: %w", tasks[i].UUID, err) + } + } + return nil +} + +func setRecurrenceUUIDContext(ctx context.Context, uuid, rec string) error { + if uuid == "" { + return fmt.Errorf("empty task UUID") + } + return runContext(ctx, "rc.recurrence.confirmation=no", uuid, "modify", "recur:"+rec) +} + // SetDueDate sets the due date for the task with the given id. func SetDueDate(id int, due string) error { return SetDueDateContext(context.Background(), id, due) diff --git a/internal/task/task_test.go b/internal/task/task_test.go index 570cc86..acd0978 100644 --- a/internal/task/task_test.go +++ b/internal/task/task_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "strings" "testing" "time" @@ -824,6 +825,100 @@ func TestRecurringSeries(t *testing.T) { } } +func TestSetRecurringSeriesRecurrenceContext(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + logFile := filepath.Join(tmp, "commands.txt") + + script := fmt.Sprintf(`#!/bin/sh +if [ "$1" = "(root or parent:root)" ] && [ "$2" = "status.any:" ] && [ "$3" = "export" ]; then + echo '{"id":0,"uuid":"root","description":"template","status":"recurring","recur":"daily"}' + echo '{"id":1,"uuid":"child-1","parent":"root","description":"child 1","status":"pending","recur":"daily"}' + echo '{"id":2,"uuid":"child-2","parent":"root","description":"child 2","status":"pending","recur":"daily"}' + exit 0 +fi +echo "$@" >> %s +`, shellQuote(logFile)) + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + if err := os.Setenv("PATH", tmp+":"+origPath); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Setenv("PATH", origPath); err != nil { + t.Errorf("restore PATH: %v", err) + } + }) + + if err := SetRecurringSeriesRecurrenceContext(context.Background(), "root", "weekly"); err != nil { + t.Fatalf("SetRecurringSeriesRecurrenceContext: %v", err) + } + + got := readLinesFile(t, logFile) + want := []string{ + "rc.recurrence.confirmation=no child-1 modify recur:weekly", + "rc.recurrence.confirmation=no child-2 modify recur:weekly", + "rc.recurrence.confirmation=no root modify recur:weekly", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("commands:\ngot %#v\nwant %#v", got, want) + } +} + +func TestSetRecurringSeriesRecurrenceContextRollsBackCompletedUpdates(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + logFile := filepath.Join(tmp, "commands.txt") + + script := fmt.Sprintf(`#!/bin/sh +if [ "$1" = "(root or parent:root)" ] && [ "$2" = "status.any:" ] && [ "$3" = "export" ]; then + echo '{"id":0,"uuid":"root","description":"template","status":"recurring","recur":"daily"}' + echo '{"id":1,"uuid":"child-1","parent":"root","description":"child 1","status":"pending","recur":"daily"}' + echo '{"id":2,"uuid":"child-2","parent":"root","description":"child 2","status":"pending","recur":"monthly"}' + exit 0 +fi +echo "$@" >> %s +if [ "$2" = "child-2" ] && [ "$4" = "recur:weekly" ]; then + echo "child-2 failed" >&2 + exit 1 +fi +`, shellQuote(logFile)) + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + if err := os.Setenv("PATH", tmp+":"+origPath); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Setenv("PATH", origPath); err != nil { + t.Errorf("restore PATH: %v", err) + } + }) + + err := SetRecurringSeriesRecurrenceContext(context.Background(), "root", "weekly") + if err == nil { + t.Fatal("expected SetRecurringSeriesRecurrenceContext error") + } + if !strings.Contains(err.Error(), "set recurrence for child-2") { + t.Fatalf("error = %v, want child-2 context", err) + } + + got := readLinesFile(t, logFile) + want := []string{ + "rc.recurrence.confirmation=no child-1 modify recur:weekly", + "rc.recurrence.confirmation=no child-2 modify recur:weekly", + "rc.recurrence.confirmation=no child-1 modify recur:daily", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("commands:\ngot %#v\nwant %#v", got, want) + } +} + func TestModifyHelpers(t *testing.T) { if _, err := exec.LookPath("task"); err != nil { t.Skip("task command not available") diff --git a/internal/task/taskwarrior.go b/internal/task/taskwarrior.go index 10b31b6..cfbf083 100644 --- a/internal/task/taskwarrior.go +++ b/internal/task/taskwarrior.go @@ -24,6 +24,7 @@ type Taskwarrior interface { RemoveTagsContext(ctx context.Context, id int, tags []string) error SetDueDateContext(ctx context.Context, id int, due string) error SetRecurrenceContext(ctx context.Context, id int, rec string) error + SetRecurringSeriesRecurrenceContext(ctx context.Context, rootUUID, rec string) error SetProjectContext(ctx context.Context, id int, project string) error SetPriorityContext(ctx context.Context, id int, priority string) error StartContext(ctx context.Context, id int) error @@ -123,6 +124,11 @@ func (Client) SetRecurrenceContext(ctx context.Context, id int, rec string) erro return SetRecurrenceContext(ctx, id, rec) } +// SetRecurringSeriesRecurrenceContext changes a recurring series recurrence value. +func (Client) SetRecurringSeriesRecurrenceContext(ctx context.Context, rootUUID, rec string) error { + return SetRecurringSeriesRecurrenceContext(ctx, rootUUID, rec) +} + // SetProjectContext changes a task project. func (Client) SetProjectContext(ctx context.Context, id int, project string) error { return SetProjectContext(ctx, id, project) diff --git a/internal/ui/detail_handlers.go b/internal/ui/detail_handlers.go index d61f29d..783af45 100644 --- a/internal/ui/detail_handlers.go +++ b/internal/ui/detail_handlers.go @@ -269,6 +269,8 @@ func (m *Model) activateProjectEdit(id int, currentProject string) { func (m *Model) activateRecurEdit(id int, currentRecur string) { m.clearEditingModes() m.recurID = id + m.recurSeries = false + m.recurRoot = "" m.recurEditing = true m.recurInput.SetValue(currentRecur) m.recurInput.Focus() diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index 7832070..9e5fcec 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -209,8 +209,14 @@ func (m *Model) handleRecurrenceMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } ctx, cancel := m.taskOperationContext() defer cancel() - if err := m.taskwarriorClient().SetRecurrenceContext(ctx, m.recurID, value); err != nil { - return err + if m.recurSeries { + if err := m.taskwarriorClient().SetRecurringSeriesRecurrenceContext(ctx, m.recurRoot, value); err != nil { + return err + } + } else { + if err := m.taskwarriorClient().SetRecurrenceContext(ctx, m.recurID, value); err != nil { + return err + } } if err := m.reload(); err != nil { return fmt.Errorf("reloading tasks: %w", err) @@ -220,6 +226,8 @@ func (m *Model) handleRecurrenceMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { onExit := func() { m.recurEditing = false + m.recurSeries = false + m.recurRoot = "" } model, cmd := m.handleTextInput(msg, &m.recurInput, onEnter, onExit) diff --git a/internal/ui/keyactions.go b/internal/ui/keyactions.go index 57a9c2e..d8ec2a7 100644 --- a/internal/ui/keyactions.go +++ b/internal/ui/keyactions.go @@ -414,6 +414,8 @@ func (m *Model) handleSetRecurrence() (tea.Model, tea.Cmd) { m.clearEditingModes() m.recurID = id + m.recurSeries = false + m.recurRoot = "" m.recurEditing = true m.recurInput.SetValue(task.Recur) m.recurInput.Focus() @@ -421,6 +423,42 @@ func (m *Model) handleSetRecurrence() (tea.Model, tea.Cmd) { return m, nil } +func (m *Model) handleSetRecurringSeriesRecurrence() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + + task := m.getTaskAtCursor() + if task == nil { + return m, nil + } + return m.activateRecurringSeriesRecurrenceEdit(id, *task) +} + +func (m *Model) activateRecurringSeriesRecurrenceEdit(id int, tsk task.Task) (tea.Model, tea.Cmd) { + if !isRecurringTask(tsk) { + m.statusMsg = "Selected task is not recurring; use R to edit this task" + return m, nil + } + + rootUUID := recurringRootUUID(tsk) + if rootUUID == "" { + m.showError(fmt.Errorf("recurring task has no root UUID")) + return m, nil + } + + m.clearEditingModes() + m.recurID = id + m.recurSeries = true + m.recurRoot = rootUUID + m.recurEditing = true + m.recurInput.SetValue(tsk.Recur) + m.recurInput.Focus() + m.updateTableHeight() + return m, nil +} + func (m *Model) handleSetPriority() (tea.Model, tea.Cmd) { id, err := m.getSelectedTaskID() if err != nil { diff --git a/internal/ui/keyhandlers.go b/internal/ui/keyhandlers.go index 690db05..3c05513 100644 --- a/internal/ui/keyhandlers.go +++ b/internal/ui/keyhandlers.go @@ -6,18 +6,19 @@ import ( ) type sharedKeyHandlers struct { - editTask func() (tea.Model, tea.Cmd) - toggleStart func() (tea.Model, tea.Cmd) - markDone func() (tea.Model, tea.Cmd) - deleteTask func() (tea.Model, tea.Cmd) - setPriority func() (tea.Model, tea.Cmd) - setDueDate func() (tea.Model, tea.Cmd) - removeDueDate func() (tea.Model, tea.Cmd) - editTags func() (tea.Model, tea.Cmd) - annotate func(replace bool) (tea.Model, tea.Cmd) - editProject func() (tea.Model, tea.Cmd) - setRecurrence func() (tea.Model, tea.Cmd) - addTask func() (tea.Model, tea.Cmd) + editTask func() (tea.Model, tea.Cmd) + toggleStart func() (tea.Model, tea.Cmd) + markDone func() (tea.Model, tea.Cmd) + deleteTask func() (tea.Model, tea.Cmd) + setPriority func() (tea.Model, tea.Cmd) + setDueDate func() (tea.Model, tea.Cmd) + removeDueDate func() (tea.Model, tea.Cmd) + editTags func() (tea.Model, tea.Cmd) + annotate func(replace bool) (tea.Model, tea.Cmd) + editProject func() (tea.Model, tea.Cmd) + setRecurrence func() (tea.Model, tea.Cmd) + setRecurSeries func() (tea.Model, tea.Cmd) + addTask func() (tea.Model, tea.Cmd) } type keyBindingMode uint8 @@ -52,6 +53,7 @@ var sharedKeyBindings = []keyBinding{ {keys: []string{"W"}, modes: keyBindingAll, desc: "remove due date", action: sharedKeyAction(func(h sharedKeyHandlers) func() (tea.Model, tea.Cmd) { return h.removeDueDate })}, {keys: []string{"r"}, modes: keyBindingAll, desc: "set random due date", action: modelKeyAction((*Model).handleRandomDueDate)}, {keys: []string{"R"}, modes: keyBindingAll, desc: "edit recurrence", action: sharedKeyAction(func(h sharedKeyHandlers) func() (tea.Model, tea.Cmd) { return h.setRecurrence })}, + {keys: []string{"ctrl+r"}, modes: keyBindingAll, desc: "edit recurring series recurrence", action: sharedKeyAction(func(h sharedKeyHandlers) func() (tea.Model, tea.Cmd) { return h.setRecurSeries })}, {keys: []string{"p"}, modes: keyBindingAll, desc: "set priority", action: sharedKeyAction(func(h sharedKeyHandlers) func() (tea.Model, tea.Cmd) { return h.setPriority })}, {keys: []string{"a"}, modes: keyBindingAll, desc: "add annotations", action: sharedAnnotateKeyAction(false)}, {keys: []string{"A"}, modes: keyBindingAll, desc: "replace annotations", action: sharedAnnotateKeyAction(true)}, @@ -143,34 +145,36 @@ func (m *Model) handleNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { func (m *Model) normalSharedKeyHandlers() sharedKeyHandlers { return sharedKeyHandlers{ - editTask: m.handleEditTask, - toggleStart: m.handleToggleStart, - markDone: m.handleMarkDone, - deleteTask: m.handleDeleteTask, - setPriority: m.handleSetPriority, - setDueDate: m.handleSetDueDate, - removeDueDate: m.handleRemoveDueDate, - editTags: m.handleEditTags, - annotate: m.handleAnnotate, - editProject: m.handleEditProject, - setRecurrence: m.handleSetRecurrence, - addTask: m.handleAddTask, + editTask: m.handleEditTask, + toggleStart: m.handleToggleStart, + markDone: m.handleMarkDone, + deleteTask: m.handleDeleteTask, + setPriority: m.handleSetPriority, + setDueDate: m.handleSetDueDate, + removeDueDate: m.handleRemoveDueDate, + editTags: m.handleEditTags, + annotate: m.handleAnnotate, + editProject: m.handleEditProject, + setRecurrence: m.handleSetRecurrence, + setRecurSeries: m.handleSetRecurringSeriesRecurrence, + addTask: m.handleAddTask, } } func (m *Model) ultraSharedKeyHandlers() sharedKeyHandlers { return sharedKeyHandlers{ - editTask: m.handleUltraEditTask, - toggleStart: m.handleUltraToggleStart, - markDone: m.handleUltraMarkDone, - deleteTask: m.handleUltraDeleteTask, - setPriority: m.handleUltraSetPriority, - setDueDate: m.handleUltraSetDueDate, - removeDueDate: m.handleUltraRemoveDueDate, - editTags: m.handleUltraEditTags, - annotate: m.handleUltraAnnotate, - editProject: m.handleUltraEditProject, - setRecurrence: m.handleUltraSetRecurrence, + editTask: m.handleUltraEditTask, + toggleStart: m.handleUltraToggleStart, + markDone: m.handleUltraMarkDone, + deleteTask: m.handleUltraDeleteTask, + setPriority: m.handleUltraSetPriority, + setDueDate: m.handleUltraSetDueDate, + removeDueDate: m.handleUltraRemoveDueDate, + editTags: m.handleUltraEditTags, + annotate: m.handleUltraAnnotate, + editProject: m.handleUltraEditProject, + setRecurrence: m.handleUltraSetRecurrence, + setRecurSeries: m.handleUltraSetRecurringSeriesRecurrence, addTask: func() (tea.Model, tea.Cmd) { m.ultraClearFocusedID() return m.handleAddTask() diff --git a/internal/ui/table.go b/internal/ui/table.go index 29ec388..ea24051 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -159,6 +159,8 @@ type editState struct { recurEditing bool recurID int + recurSeries bool + recurRoot string recurInput textinput.Model projEditing bool @@ -392,6 +394,8 @@ func (m *Model) clearEditingModes() { m.tagsEditing = false m.dueEditing = false m.recurEditing = false + m.recurSeries = false + m.recurRoot = "" m.projEditing = false m.filterEditing = false m.addingTask = false @@ -991,6 +995,7 @@ func (m *Model) helpSections() []uihelp.Section { {Key: "w, W", Desc: "set/remove due date"}, {Key: "r", Desc: "set random due date"}, {Key: "R", Desc: "edit recurrence"}, + {Key: "ctrl+r", Desc: "edit recurring series recurrence"}, {Key: "t", Desc: "edit tags"}, {Key: "J", Desc: "edit project"}, {Key: "T", Desc: "convert first tag to project"}, @@ -1500,6 +1505,7 @@ var reservedAgentHotkeys = map[string]struct{}{ "J": {}, "N": {}, "R": {}, + "ctrl+r": {}, "T": {}, "U": {}, "W": {}, diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 74a5f57..30716f1 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -21,13 +21,27 @@ import ( ) type fakeTaskwarrior struct { - tasks []task.Task - exportFilters [][]string - addLines []string + tasks []task.Task + exportFilters [][]string + addLines []string + recurrences []fakeRecurrenceChange + seriesRecurrences []fakeSeriesRecurrenceChange + setRecurrenceErr error + setSeriesRecurrenceErr error } var _ task.Taskwarrior = (*fakeTaskwarrior)(nil) +type fakeRecurrenceChange struct { + id int + rec string +} + +type fakeSeriesRecurrenceChange struct { + rootUUID string + rec string +} + func (f *fakeTaskwarrior) Export(_ context.Context, filters ...string) ([]task.Task, error) { f.exportFilters = append(f.exportFilters, append([]string(nil), filters...)) return append([]task.Task(nil), f.tasks...), nil @@ -105,9 +119,14 @@ func (f *fakeTaskwarrior) SetDueDateContext(context.Context, int, string) error return nil } -func (f *fakeTaskwarrior) SetRecurrenceContext(context.Context, int, string) error { - f.unexpected("SetRecurrenceContext") - return nil +func (f *fakeTaskwarrior) SetRecurrenceContext(_ context.Context, id int, rec string) error { + f.recurrences = append(f.recurrences, fakeRecurrenceChange{id: id, rec: rec}) + return f.setRecurrenceErr +} + +func (f *fakeTaskwarrior) SetRecurringSeriesRecurrenceContext(_ context.Context, rootUUID, rec string) error { + f.seriesRecurrences = append(f.seriesRecurrences, fakeSeriesRecurrenceChange{rootUUID: rootUUID, rec: rec}) + return f.setSeriesRecurrenceErr } func (f *fakeTaskwarrior) SetProjectContext(context.Context, int, string) error { @@ -149,6 +168,10 @@ func (f *fakeTaskwarrior) unexpected(method string) { panic(fmt.Sprintf("unexpected fake Taskwarrior call: %s", method)) } +func ctrlRKey() tea.KeyPressMsg { + return tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl} +} + func TestNewWithTaskwarriorUsesFakeForAddTask(t *testing.T) { fake := &fakeTaskwarrior{ tasks: []task.Task{ @@ -1483,6 +1506,70 @@ func TestRecurrenceHotkey(t *testing.T) { } } +func TestRecurringSeriesRecurrenceHotkey(t *testing.T) { + fake := &fakeTaskwarrior{ + tasks: []task.Task{ + {ID: 7, UUID: "child", Parent: "root", Description: "child", Status: "pending", Recur: "daily", RType: "periodic"}, + }, + } + m, err := NewWithTaskwarrior(nil, "firefox", fake) + if err != nil { + t.Fatalf("NewWithTaskwarrior: %v", err) + } + + mv, _ := (&m).Update(ctrlRKey()) + m = *mv.(*Model) + if !m.recurEditing { + t.Fatalf("recurring series recurrence edit was not activated") + } + if !m.recurSeries { + t.Fatalf("recurrence edit is not marked as series scoped") + } + if m.recurRoot != "root" { + t.Fatalf("recurring root = %q, want root", m.recurRoot) + } + m.recurInput.SetValue("weekly") + + mv, _ = (&m).Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = *mv.(*Model) + + want := []fakeSeriesRecurrenceChange{{rootUUID: "root", rec: "weekly"}} + if !reflect.DeepEqual(fake.seriesRecurrences, want) { + t.Fatalf("series recurrence changes = %#v, want %#v", fake.seriesRecurrences, want) + } + if len(fake.recurrences) != 0 { + t.Fatalf("single recurrence changes = %#v, want none", fake.recurrences) + } + if m.recurSeries || m.recurRoot != "" { + t.Fatalf("series recurrence state not reset: series=%v root=%q", m.recurSeries, m.recurRoot) + } +} + +func TestRecurringSeriesRecurrenceHotkeyRejectsNonRecurringTask(t *testing.T) { + fake := &fakeTaskwarrior{ + tasks: []task.Task{ + {ID: 1, UUID: "single", Description: "single", Status: "pending"}, + }, + } + m, err := NewWithTaskwarrior(nil, "firefox", fake) + if err != nil { + t.Fatalf("NewWithTaskwarrior: %v", err) + } + + mv, _ := (&m).Update(ctrlRKey()) + m = *mv.(*Model) + + if m.recurEditing { + t.Fatalf("recurrence edit activated for non-recurring task") + } + if len(fake.seriesRecurrences) != 0 { + t.Fatalf("series recurrence changes = %#v, want none", fake.seriesRecurrences) + } + if got, want := m.statusMsg, "Selected task is not recurring; use R to edit this task"; got != want { + t.Fatalf("statusMsg = %q, want %q", got, want) + } +} + func TestHandleRecurrenceModeDetailBlinkTargetsRecurField(t *testing.T) { m := newRecurrenceDetailModel(t, "") diff --git a/internal/ui/ultra.go b/internal/ui/ultra.go index e4f5e17..87011ab 100644 --- a/internal/ui/ultra.go +++ b/internal/ui/ultra.go @@ -110,6 +110,7 @@ func (m *Model) ultraHelpSections() []uihelp.Section { {Key: "a, A", Desc: "add/replace annotations"}, {Key: "J", Desc: "edit project"}, {Key: "R", Desc: "edit recurrence"}, + {Key: "ctrl+r", Desc: "edit recurring series recurrence"}, {Key: m.agentFilterHotkeyLabel(), Desc: "toggle +agent/-agent filter"}, {Key: "f", Desc: "change filter"}, }, @@ -1267,3 +1268,11 @@ func (m *Model) handleUltraSetRecurrence() (tea.Model, tea.Cmd) { return m.handleSetRecurrence() } + +func (m *Model) handleUltraSetRecurringSeriesRecurrence() (tea.Model, tea.Cmd) { + if _, ok := m.ultraPrepareSelectedTask(); !ok { + return m, nil + } + + return m.handleSetRecurringSeriesRecurrence() +} |
