summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-25 18:18:59 +0300
committerPaul Buetow <paul@buetow.org>2026-06-25 18:18:59 +0300
commitbca7d87f11a3ee88c75dc1ed706009d63022ef98 (patch)
tree70b5bed95f11bc30968917d8c088fa8b7697cfac
parentb642f71049ba6030e45c9ddead6d1cc2b7e289e2 (diff)
fr0 add recurring series recurrence edit
-rw-r--r--README.md3
-rw-r--r--internal/task/crud.go66
-rw-r--r--internal/task/task_test.go95
-rw-r--r--internal/task/taskwarrior.go6
-rw-r--r--internal/ui/detail_handlers.go2
-rw-r--r--internal/ui/handlers.go12
-rw-r--r--internal/ui/keyactions.go38
-rw-r--r--internal/ui/keyhandlers.go74
-rw-r--r--internal/ui/table.go6
-rw-r--r--internal/ui/table_test.go99
-rw-r--r--internal/ui/ultra.go9
11 files changed, 367 insertions, 43 deletions
diff --git a/README.md b/README.md
index 2b81611..926bfa6 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,9 @@ Press `H` to view all available hotkeys.
Example: press `+`, type `Buy milk` and hit Enter to add a new task called "Buy milk".
+Press `R` to edit the selected task's recurrence. On a recurring task, press
+`Ctrl+R` to edit the recurrence across the known recurring series.
+
Press `:` in either table or ultra mode to open a Taskwarrior command prompt.
The prompt supplies `task`; type arguments such as `add Buy milk`, `projects`,
or `+home list`. Press `;` to open the same prompt pre-filled with the selected
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()
+}