summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-06-28 00:00:15 +0300
committerPaul Buetow <paul@buetow.org>2025-06-28 00:00:15 +0300
commit0e065b3b0f5e935fc769be2f1e84779fa9897e99 (patch)
treee72775ab2fba73100955ac04b2c66e2d567fe7b6 /internal
parente527f6084f4a3f592d06c25e34e08cc3769706a8 (diff)
fix: resolve test failures and improve code quality
- Fix file handle leak in SetDebugLog by tracking and closing previous files - Add stderr capture to all taskwarrior commands for better error messages - Fix timezone issues in date handling tests by normalizing to UTC - Change Update method to pointer receiver for consistency - Update all test type assertions to handle pointer receivers correctly - Remove unused imports and variables All tests now pass successfully. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/task/operations_test.go133
-rw-r--r--internal/task/task.go71
-rw-r--r--internal/ui/handlers.go437
-rw-r--r--internal/ui/helpers.go191
-rw-r--r--internal/ui/helpers_test.go363
-rw-r--r--internal/ui/keyhandlers.go493
-rw-r--r--internal/ui/table.go723
-rw-r--r--internal/ui/table_test.go149
-rw-r--r--internal/ui/theme.go3
9 files changed, 1807 insertions, 756 deletions
diff --git a/internal/task/operations_test.go b/internal/task/operations_test.go
new file mode 100644
index 0000000..7abd4bd
--- /dev/null
+++ b/internal/task/operations_test.go
@@ -0,0 +1,133 @@
+package task
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestModifyTask(t *testing.T) {
+ tests := []struct {
+ name string
+ id int
+ args []string
+ wantErr bool
+ errMsg string
+ }{
+ {
+ name: "valid ID",
+ id: 1,
+ args: []string{"status:pending"},
+ wantErr: false,
+ },
+ {
+ name: "zero ID",
+ id: 0,
+ args: []string{"status:pending"},
+ wantErr: true,
+ errMsg: "invalid task ID: 0",
+ },
+ {
+ name: "negative ID",
+ id: -1,
+ args: []string{"status:pending"},
+ wantErr: true,
+ errMsg: "invalid task ID: -1",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := modifyTask(tt.id, tt.args...)
+
+ // We can't test actual taskwarrior commands without it installed
+ // So we just test the validation
+ if tt.wantErr {
+ if err == nil {
+ t.Errorf("modifyTask() error = nil, wantErr %v", tt.wantErr)
+ } else if !strings.Contains(err.Error(), tt.errMsg) {
+ t.Errorf("modifyTask() error = %v, want error containing %v", err, tt.errMsg)
+ }
+ }
+ })
+ }
+}
+
+func TestSimpleTaskCommand(t *testing.T) {
+ tests := []struct {
+ name string
+ id int
+ command string
+ wantErr bool
+ errMsg string
+ }{
+ {
+ name: "valid ID",
+ id: 1,
+ command: "done",
+ wantErr: false,
+ },
+ {
+ name: "zero ID",
+ id: 0,
+ command: "done",
+ wantErr: true,
+ errMsg: "invalid task ID: 0",
+ },
+ {
+ name: "negative ID",
+ id: -5,
+ command: "done",
+ wantErr: true,
+ errMsg: "invalid task ID: -5",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := simpleTaskCommand(tt.id, tt.command)
+
+ // We can't test actual taskwarrior commands without it installed
+ // So we just test the validation
+ if tt.wantErr {
+ if err == nil {
+ t.Errorf("simpleTaskCommand() error = nil, wantErr %v", tt.wantErr)
+ } else if !strings.Contains(err.Error(), tt.errMsg) {
+ t.Errorf("simpleTaskCommand() error = %v, want error containing %v", err, tt.errMsg)
+ }
+ }
+ })
+ }
+}
+
+func TestTaskOperationsValidation(t *testing.T) {
+ // Test that all task operations validate IDs
+ invalidID := -1
+
+ operations := []struct {
+ name string
+ fn func() error
+ }{
+ {"SetStatus", func() error { return SetStatus(invalidID, "pending") }},
+ {"Start", func() error { return Start(invalidID) }},
+ {"Stop", func() error { return Stop(invalidID) }},
+ {"Done", func() error { return Done(invalidID) }},
+ {"Delete", func() error { return Delete(invalidID) }},
+ {"SetPriority", func() error { return SetPriority(invalidID, "H") }},
+ {"SetRecurrence", func() error { return SetRecurrence(invalidID, "daily") }},
+ {"SetDueDate", func() error { return SetDueDate(invalidID, "tomorrow") }},
+ {"SetDescription", func() error { return SetDescription(invalidID, "test") }},
+ {"Annotate", func() error { return Annotate(invalidID, "note") }},
+ {"Denotate", func() error { return Denotate(invalidID, "note") }},
+ }
+
+ for _, op := range operations {
+ t.Run(op.name, func(t *testing.T) {
+ err := op.fn()
+ if err == nil {
+ t.Errorf("%s() with invalid ID = nil, want error", op.name)
+ } else if !strings.Contains(err.Error(), "invalid task ID") {
+ t.Errorf("%s() error = %v, want error containing 'invalid task ID'", op.name, err)
+ }
+ })
+ }
+} \ No newline at end of file
diff --git a/internal/task/task.go b/internal/task/task.go
index be3b6ce..86c93f4 100644
--- a/internal/task/task.go
+++ b/internal/task/task.go
@@ -155,12 +155,25 @@ func run(args ...string) error {
return nil
}
-// SetStatus changes the status of the task with the given id.
-func SetStatus(id int, status string) error {
+// modifyTask runs a modify command with validation
+func modifyTask(id int, args ...string) error {
if id <= 0 {
return fmt.Errorf("invalid task ID: %d", id)
}
- return run(strconv.Itoa(id), "modify", "status:"+status)
+ return run(append([]string{strconv.Itoa(id), "modify"}, args...)...)
+}
+
+// simpleTaskCommand runs a simple command on a task with validation
+func simpleTaskCommand(id int, command string) error {
+ if id <= 0 {
+ return fmt.Errorf("invalid task ID: %d", id)
+ }
+ return run(strconv.Itoa(id), command)
+}
+
+// SetStatus changes the status of the task with the given id.
+func SetStatus(id int, status string) error {
+ return modifyTask(id, "status:"+status)
}
// SetStatusUUID changes the status of the task with the given UUID.
@@ -170,42 +183,27 @@ func SetStatusUUID(uuid, status string) error {
// Start begins the task with the given id.
func Start(id int) error {
- if id <= 0 {
- return fmt.Errorf("invalid task ID: %d", id)
- }
- return run(strconv.Itoa(id), "start")
+ return simpleTaskCommand(id, "start")
}
// Stop stops the task with the given id.
func Stop(id int) error {
- if id <= 0 {
- return fmt.Errorf("invalid task ID: %d", id)
- }
- return run(strconv.Itoa(id), "stop")
+ return simpleTaskCommand(id, "stop")
}
// Done marks the task with the given id as completed.
func Done(id int) error {
- if id <= 0 {
- return fmt.Errorf("invalid task ID: %d", id)
- }
- return run(strconv.Itoa(id), "done")
+ return simpleTaskCommand(id, "done")
}
// Delete removes the task with the given id.
func Delete(id int) error {
- if id <= 0 {
- return fmt.Errorf("invalid task ID: %d", id)
- }
- return run(strconv.Itoa(id), "delete")
+ return simpleTaskCommand(id, "delete")
}
// SetPriority changes the priority of the task with the given id.
func SetPriority(id int, priority string) error {
- if id <= 0 {
- return fmt.Errorf("invalid task ID: %d", id)
- }
- return run(strconv.Itoa(id), "modify", "priority:"+priority)
+ return modifyTask(id, "priority:"+priority)
}
// AddTags adds tags to the task with the given id.
@@ -287,26 +285,17 @@ func SetTags(id int, tags []string) error {
// SetRecurrence sets the recurrence for the task with the given id.
func SetRecurrence(id int, rec string) error {
- if id <= 0 {
- return fmt.Errorf("invalid task ID: %d", id)
- }
- return run(strconv.Itoa(id), "modify", "recur:"+rec)
+ return modifyTask(id, "recur:"+rec)
}
// SetDueDate sets the due date for the task with the given id.
func SetDueDate(id int, due string) error {
- if id <= 0 {
- return fmt.Errorf("invalid task ID: %d", id)
- }
- return run(strconv.Itoa(id), "modify", "due:"+due)
+ return modifyTask(id, "due:"+due)
}
// SetDescription changes the description of the task with the given id.
func SetDescription(id int, desc string) error {
- if id <= 0 {
- return fmt.Errorf("invalid task ID: %d", id)
- }
- return run(strconv.Itoa(id), "modify", "description:"+desc)
+ return modifyTask(id, "description:"+desc)
}
// Annotate adds an annotation to the task with the given id.
@@ -388,7 +377,16 @@ func Edit(id int) error {
// Started tasks are always placed before non-started ones. Tasks without a due
// date are placed after tasks with a due date. Overdue tasks are placed at the
// very top regardless of other properties.
+//
+// The sort order is:
+// 1. Overdue tasks (oldest due date first)
+// 2. Started tasks (not completed)
+// 3. High priority tasks
+// 4. Tasks with earlier due dates
+// 5. Tasks sorted alphabetically by tags
+// 6. Tasks sorted by ID (oldest first)
func SortTasks(tasks []Task) {
+ // Helper to join tags in a consistent order for comparison
joinTags := func(tags []string) string {
if len(tags) == 0 {
return ""
@@ -398,6 +396,7 @@ func SortTasks(tasks []Task) {
return strings.Join(cpy, " ")
}
+ // Convert priority to numeric value for comparison (higher = more important)
priVal := func(p string) int {
switch p {
case "H":
@@ -411,6 +410,7 @@ func SortTasks(tasks []Task) {
}
}
+ // Parse due date string into time.Time
parseDue := func(s string) (time.Time, bool) {
if s == "" {
return time.Time{}, false
@@ -422,6 +422,7 @@ func SortTasks(tasks []Task) {
return t, true
}
+ // Check if a task is overdue
overdue := func(t Task) bool {
du, ok := parseDue(t.Due)
return ok && time.Now().After(du)
diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go
new file mode 100644
index 0000000..7fc734b
--- /dev/null
+++ b/internal/ui/handlers.go
@@ -0,0 +1,437 @@
+package ui
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/x/ansi"
+
+ "codeberg.org/snonux/tasksamurai/internal/task"
+)
+
+// handleTextInput provides generic text input handling for all input modes
+func (m *Model) handleTextInput(msg tea.KeyMsg, input *textinput.Model, onEnter func(string) error, onExit func()) (tea.Model, tea.Cmd) {
+ switch msg.Type {
+ case tea.KeyEnter:
+ value := input.Value()
+ if err := onEnter(value); err != nil {
+ m.statusMsg = fmt.Sprintf("Error: %v", err)
+ cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg {
+ return struct{ clearStatus bool }{true}
+ })
+ return m, cmd
+ }
+ input.Blur()
+ onExit()
+ m.updateTableHeight()
+ return m, nil
+ case tea.KeyEsc:
+ input.Blur()
+ onExit()
+ m.updateTableHeight()
+ return m, nil
+ }
+ var cmd tea.Cmd
+ *input, cmd = input.Update(msg)
+ return m, cmd
+}
+
+// handleAnnotationMode handles keyboard input when in annotation mode
+func (m *Model) handleAnnotationMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ onEnter := func(value string) error {
+ // Annotation can be empty when replacing (to remove all)
+ if !m.replaceAnnotations && strings.TrimSpace(value) == "" {
+ return fmt.Errorf("annotation cannot be empty")
+ }
+
+ if m.replaceAnnotations {
+ if err := task.ReplaceAnnotations(m.annotateID, value); err != nil {
+ return err
+ }
+ m.replaceAnnotations = false
+ } else {
+ if err := task.Annotate(m.annotateID, value); err != nil {
+ return err
+ }
+ }
+ m.reload()
+ return nil
+ }
+
+ onExit := func() {
+ m.annotating = false
+ m.replaceAnnotations = false
+ }
+
+ model, cmd := m.handleTextInput(msg, &m.annotateInput, onEnter, onExit)
+ if msg.Type == tea.KeyEnter && m.annotateInput.Value() != "" {
+ // Start blink after successful annotation
+ return model, m.startBlink(m.annotateID, false)
+ }
+ return model, cmd
+}
+
+// handleDescriptionMode handles keyboard input when editing description
+func (m *Model) handleDescriptionMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ onEnter := func(value string) error {
+ if err := validateDescription(value); err != nil {
+ return err
+ }
+ if err := task.SetDescription(m.descID, value); err != nil {
+ return err
+ }
+ m.reload()
+ return nil
+ }
+
+ onExit := func() {
+ m.descEditing = false
+ }
+
+ model, cmd := m.handleTextInput(msg, &m.descInput, onEnter, onExit)
+ if msg.Type == tea.KeyEnter {
+ return model, m.startBlink(m.descID, false)
+ }
+ return model, cmd
+}
+
+// handleTagsMode handles keyboard input when editing tags
+func (m *Model) handleTagsMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ onEnter := func(value string) error {
+ words := strings.Fields(value)
+ var adds, removes []string
+ for _, w := range words {
+ if strings.HasPrefix(w, "-") {
+ if len(w) > 1 {
+ tagName := w[1:]
+ if err := validateTagName(tagName); err != nil {
+ return fmt.Errorf("remove tag '%s': %w", tagName, err)
+ }
+ removes = append(removes, tagName)
+ }
+ } else {
+ if strings.HasPrefix(w, "+") {
+ w = w[1:]
+ }
+ if w != "" {
+ if err := validateTagName(w); err != nil {
+ return fmt.Errorf("add tag '%s': %w", w, err)
+ }
+ adds = append(adds, w)
+ }
+ }
+ }
+ if len(adds) > 0 {
+ if err := task.AddTags(m.tagsID, adds); err != nil {
+ return err
+ }
+ }
+ if len(removes) > 0 {
+ if err := task.RemoveTags(m.tagsID, removes); err != nil {
+ return err
+ }
+ }
+ m.reload()
+ return nil
+ }
+
+ onExit := func() {
+ m.tagsEditing = false
+ }
+
+ model, cmd := m.handleTextInput(msg, &m.tagsInput, onEnter, onExit)
+ if msg.Type == tea.KeyEnter {
+ return model, m.startBlink(m.tagsID, false)
+ }
+ return model, cmd
+}
+
+// handleDueEditMode handles due date editing
+func (m *Model) handleDueEditMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.Type {
+ case tea.KeyEnter:
+ if err := task.SetDueDate(m.dueID, m.dueDate.Format("2006-01-02")); err != nil {
+ m.statusMsg = fmt.Sprintf("Error: %v", err)
+ cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg {
+ return struct{ clearStatus bool }{true}
+ })
+ return m, cmd
+ }
+ m.dueEditing = false
+ m.reload()
+ cmd := m.startBlink(m.dueID, false)
+ m.updateTableHeight()
+ return m, cmd
+ case tea.KeyEsc:
+ m.dueEditing = false
+ m.updateTableHeight()
+ return m, nil
+ }
+
+ switch msg.String() {
+ case "h", "left":
+ m.dueDate = m.dueDate.AddDate(0, 0, -1)
+ case "l", "right":
+ m.dueDate = m.dueDate.AddDate(0, 0, 1)
+ case "k", "up":
+ m.dueDate = m.dueDate.AddDate(0, 0, -7)
+ case "j", "down":
+ m.dueDate = m.dueDate.AddDate(0, 0, 7)
+ }
+ return m, nil
+}
+
+// handleRecurrenceMode handles recurrence editing
+func (m *Model) handleRecurrenceMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ onEnter := func(value string) error {
+ if err := validateRecurrence(value); err != nil {
+ return err
+ }
+ if err := task.SetRecurrence(m.recurID, value); err != nil {
+ return err
+ }
+ m.reload()
+ return nil
+ }
+
+ onExit := func() {
+ m.recurEditing = false
+ }
+
+ model, cmd := m.handleTextInput(msg, &m.recurInput, onEnter, onExit)
+ if msg.Type == tea.KeyEnter {
+ return model, m.startBlink(m.recurID, false)
+ }
+ return model, cmd
+}
+
+// handlePriorityMode handles priority selection
+func (m *Model) handlePriorityMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.Type {
+ case tea.KeyEnter:
+ priority := priorityOptions[m.priorityIndex]
+ if err := validatePriority(priority); err != nil {
+ m.statusMsg = fmt.Sprintf("Error: %v", err)
+ cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg {
+ return struct{ clearStatus bool }{true}
+ })
+ return m, cmd
+ }
+ if err := task.SetPriority(m.priorityID, priority); err != nil {
+ m.statusMsg = fmt.Sprintf("Error: %v", err)
+ cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg {
+ return struct{ clearStatus bool }{true}
+ })
+ return m, cmd
+ }
+ m.prioritySelecting = false
+ m.reload()
+ cmd := m.startBlink(m.priorityID, false)
+ m.updateTableHeight()
+ return m, cmd
+ case tea.KeyEsc:
+ m.prioritySelecting = false
+ m.updateTableHeight()
+ return m, nil
+ }
+
+ switch msg.String() {
+ case "h", "left":
+ m.priorityIndex = (m.priorityIndex + len(priorityOptions) - 1) % len(priorityOptions)
+ case "l", "right":
+ m.priorityIndex = (m.priorityIndex + 1) % len(priorityOptions)
+ }
+ return m, nil
+}
+
+// handleFilterMode handles filter editing
+func (m *Model) handleFilterMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ onEnter := func(value string) error {
+ m.filters = strings.Fields(value)
+ m.reload()
+ return nil
+ }
+
+ onExit := func() {
+ m.filterEditing = false
+ }
+
+ return m.handleTextInput(msg, &m.filterInput, onEnter, onExit)
+}
+
+// handleAddTaskMode handles adding a new task
+func (m *Model) handleAddTaskMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.Type {
+ case tea.KeyEnter:
+ oldIDs := make(map[int]struct{})
+ for _, tsk := range m.tasks {
+ oldIDs[tsk.ID] = struct{}{}
+ }
+
+ if err := task.AddLine(m.addInput.Value()); err != nil {
+ m.statusMsg = fmt.Sprintf("Error: %v", err)
+ cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg {
+ return struct{ clearStatus bool }{true}
+ })
+ return m, cmd
+ }
+
+ m.addingTask = false
+ m.addInput.Blur()
+ m.reload()
+
+ // Find the newly added task
+ var newID int
+ row := -1
+ for i, tsk := range m.tasks {
+ if _, ok := oldIDs[tsk.ID]; !ok {
+ newID = tsk.ID
+ row = i
+ break
+ }
+ }
+
+ m.updateTableHeight()
+ if row >= 0 {
+ prevRow := m.tbl.Cursor()
+ prevCol := m.tbl.ColumnCursor()
+ m.tbl.SetCursor(row)
+ m.tbl.SetColumnCursor(7) // Description column
+ m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor())
+ return m, m.startBlink(newID, false)
+ }
+ return m, nil
+
+ case tea.KeyEsc:
+ m.addingTask = false
+ m.addInput.Blur()
+ m.updateTableHeight()
+ return m, nil
+ }
+
+ var cmd tea.Cmd
+ m.addInput, cmd = m.addInput.Update(msg)
+ return m, cmd
+}
+
+// handleSearchMode handles search input
+func (m *Model) handleSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.Type {
+ case tea.KeyEnter:
+ pattern := m.searchInput.Value()
+ if pattern != "" {
+ // Check cache first
+ if cached, ok := searchRegexCache[pattern]; ok {
+ m.searchRegex = cached
+ } else {
+ // Compile and cache if not found
+ re, err := compileAndCacheRegex(pattern)
+ if err == nil {
+ m.searchRegex = re
+ } else {
+ m.searchRegex = nil
+ m.statusMsg = fmt.Sprintf("Invalid regex: %v", err)
+ }
+ }
+ } else {
+ m.searchRegex = nil
+ }
+ m.searching = false
+ m.searchInput.Blur()
+ m.reload()
+ m.updateTableHeight()
+
+ if len(m.searchMatches) > 0 {
+ match := m.searchMatches[m.searchIndex]
+ prevRow := m.tbl.Cursor()
+ prevCol := m.tbl.ColumnCursor()
+ m.tbl.SetCursor(match.row)
+ m.tbl.SetColumnCursor(match.col)
+ m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor())
+ }
+ return m, nil
+
+ case tea.KeyEsc:
+ m.searching = false
+ m.searchInput.Blur()
+ m.updateTableHeight()
+ return m, nil
+ }
+
+ var cmd tea.Cmd
+ m.searchInput, cmd = m.searchInput.Update(msg)
+ return m, cmd
+}
+
+// handleBlinkingState handles input when a task is blinking
+func (m *Model) handleBlinkingState(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if _, ok := msg.(tea.KeyMsg); ok {
+ // Only allow navigation while blinking
+ prevRow := m.tbl.Cursor()
+ prevCol := m.tbl.ColumnCursor()
+ var cmd tea.Cmd
+ m.tbl, cmd = m.tbl.Update(msg)
+ if prevRow != m.tbl.Cursor() || prevCol != m.tbl.ColumnCursor() {
+ m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor())
+ }
+ return m, cmd
+ }
+ return m, nil
+}
+
+// handleEditingModes checks if we're in any editing mode and handles it
+func (m *Model) handleEditingModes(msg tea.KeyMsg) (handled bool, model tea.Model, cmd tea.Cmd) {
+ switch {
+ case m.annotating:
+ model, cmd = m.handleAnnotationMode(msg)
+ return true, model, cmd
+ case m.descEditing:
+ model, cmd = m.handleDescriptionMode(msg)
+ return true, model, cmd
+ case m.tagsEditing:
+ model, cmd = m.handleTagsMode(msg)
+ return true, model, cmd
+ case m.dueEditing:
+ model, cmd = m.handleDueEditMode(msg)
+ return true, model, cmd
+ case m.recurEditing:
+ model, cmd = m.handleRecurrenceMode(msg)
+ return true, model, cmd
+ case m.prioritySelecting:
+ model, cmd = m.handlePriorityMode(msg)
+ return true, model, cmd
+ case m.filterEditing:
+ model, cmd = m.handleFilterMode(msg)
+ return true, model, cmd
+ case m.addingTask:
+ model, cmd = m.handleAddTaskMode(msg)
+ return true, model, cmd
+ case m.searching:
+ model, cmd = m.handleSearchMode(msg)
+ return true, model, cmd
+ }
+ return false, m, nil
+}
+
+// getSelectedTaskID extracts the task ID from the selected row
+func (m *Model) getSelectedTaskID() (int, error) {
+ row := m.tbl.SelectedRow()
+ if row == nil {
+ return 0, fmt.Errorf("no row selected")
+ }
+ idStr := ansi.Strip(row[1])
+ return strconv.Atoi(idStr)
+}
+
+// getTaskAtCursor returns the task at the current cursor position
+func (m *Model) getTaskAtCursor() *task.Task {
+ cursor := m.tbl.Cursor()
+ if cursor < 0 || cursor >= len(m.tasks) {
+ return nil
+ }
+ return &m.tasks[cursor]
+} \ No newline at end of file
diff --git a/internal/ui/helpers.go b/internal/ui/helpers.go
new file mode 100644
index 0000000..71c21db
--- /dev/null
+++ b/internal/ui/helpers.go
@@ -0,0 +1,191 @@
+package ui
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// Date format used by Taskwarrior
+const taskDateFormat = "20060102T150405Z"
+
+// parseTaskDate parses a date string in Taskwarrior format
+func parseTaskDate(dateStr string) (time.Time, error) {
+ if dateStr == "" {
+ return time.Time{}, fmt.Errorf("empty date string")
+ }
+ return time.Parse(taskDateFormat, dateStr)
+}
+
+// formatTaskDate formats a time as a Taskwarrior date string
+func formatTaskDate(t time.Time) string {
+ return t.UTC().Format(taskDateFormat)
+}
+
+// daysSince returns the number of days since the given time
+func daysSince(t time.Time) int {
+ return int(time.Since(t).Hours() / 24)
+}
+
+// daysUntil returns the number of days until the given time
+func daysUntil(t time.Time) int {
+ now := time.Now()
+ // Normalize both times to midnight UTC to avoid timezone and fractional day issues
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
+ target := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
+ return int(target.Sub(today).Hours() / 24)
+}
+
+// formatDueText returns a human-readable due date string
+func formatDueText(dueStr string) string {
+ if dueStr == "" {
+ return ""
+ }
+
+ ts, err := parseTaskDate(dueStr)
+ if err != nil {
+ return dueStr
+ }
+
+ days := daysUntil(ts)
+ switch days {
+ case 0:
+ return "today"
+ case 1:
+ return "tomorrow"
+ case -1:
+ return "yesterday"
+ default:
+ return fmt.Sprintf("%dd", days)
+ }
+}
+
+// compileAndCacheRegex compiles a regex and adds it to the cache
+func compileAndCacheRegex(pattern string) (*regexp.Regexp, error) {
+ re, err := regexp.Compile(pattern)
+ if err != nil {
+ return nil, err
+ }
+
+ // Limit cache size to prevent memory leak
+ if len(searchRegexCache) > 100 {
+ // Clear cache when it gets too large
+ searchRegexCache = make(map[string]*regexp.Regexp)
+ }
+ searchRegexCache[pattern] = re
+
+ return re, nil
+}
+
+// Validation functions
+
+// validateTagName validates a tag name
+func validateTagName(tag string) error {
+ if tag == "" {
+ return fmt.Errorf("tag cannot be empty")
+ }
+
+ // Remove leading + or - for validation
+ tag = strings.TrimPrefix(strings.TrimPrefix(tag, "+"), "-")
+
+ // Check for invalid characters
+ if strings.ContainsAny(tag, " \t\n\r") {
+ return fmt.Errorf("tag cannot contain whitespace")
+ }
+
+ return nil
+}
+
+// validateTags validates a list of tags
+func validateTags(tags []string) error {
+ for _, tag := range tags {
+ if err := validateTagName(tag); err != nil {
+ return fmt.Errorf("invalid tag '%s': %w", tag, err)
+ }
+ }
+ return nil
+}
+
+// validateDueDate validates a due date string
+func validateDueDate(due string) error {
+ if due == "" {
+ return nil // Empty due date is valid
+ }
+
+ // Try common formats
+ formats := []string{
+ "2006-01-02",
+ "2006-01-02T15:04:05",
+ "2006-01-02T15:04:05Z",
+ taskDateFormat,
+ }
+
+ for _, format := range formats {
+ if _, err := time.Parse(format, due); err == nil {
+ return nil
+ }
+ }
+
+ // Check for relative dates that taskwarrior understands
+ relatives := []string{"now", "today", "tomorrow", "yesterday", "monday", "tuesday",
+ "wednesday", "thursday", "friday", "saturday", "sunday", "eod", "eow", "eom", "eoy"}
+
+ due = strings.ToLower(due)
+ for _, rel := range relatives {
+ if due == rel || strings.HasPrefix(due, rel+"+") || strings.HasPrefix(due, rel+"-") {
+ return nil
+ }
+ }
+
+ return fmt.Errorf("invalid due date format: %s", due)
+}
+
+// validatePriority validates a priority value
+func validatePriority(priority string) error {
+ switch priority {
+ case "", "H", "M", "L":
+ return nil
+ default:
+ return fmt.Errorf("invalid priority: %s (must be H, M, L, or empty)", priority)
+ }
+}
+
+// validateRecurrence validates a recurrence string
+func validateRecurrence(recur string) error {
+ if recur == "" {
+ return nil // Empty recurrence is valid
+ }
+
+ // Basic validation - taskwarrior will do the full validation
+ if len(recur) < 2 {
+ return fmt.Errorf("recurrence too short")
+ }
+
+ // Check for common patterns
+ validPrefixes := []string{"daily", "weekly", "monthly", "yearly", "biweekly", "bimonthly"}
+ for _, prefix := range validPrefixes {
+ if strings.HasPrefix(strings.ToLower(recur), prefix) {
+ return nil
+ }
+ }
+
+ // Check for duration format (e.g., "3d", "2w", "1m")
+ if len(recur) >= 2 {
+ last := recur[len(recur)-1]
+ if (last == 'd' || last == 'w' || last == 'm' || last == 'y') &&
+ recur[:len(recur)-1] != "" {
+ return nil
+ }
+ }
+
+ return nil // Let taskwarrior handle complex validation
+}
+
+// validateDescription validates a task description
+func validateDescription(desc string) error {
+ if strings.TrimSpace(desc) == "" {
+ return fmt.Errorf("description cannot be empty")
+ }
+ return nil
+} \ No newline at end of file
diff --git a/internal/ui/helpers_test.go b/internal/ui/helpers_test.go
new file mode 100644
index 0000000..9ba620c
--- /dev/null
+++ b/internal/ui/helpers_test.go
@@ -0,0 +1,363 @@
+package ui
+
+import (
+ "testing"
+ "time"
+)
+
+func TestParseTaskDate(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantErr bool
+ }{
+ {
+ name: "valid date",
+ input: "20250627T150405Z",
+ wantErr: false,
+ },
+ {
+ name: "empty string",
+ input: "",
+ wantErr: true,
+ },
+ {
+ name: "invalid format",
+ input: "2025-06-27",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := parseTaskDate(tt.input)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("parseTaskDate() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestFormatDueText(t *testing.T) {
+ now := time.Now()
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "empty",
+ input: "",
+ expected: "",
+ },
+ {
+ name: "today",
+ input: now.UTC().Format("20060102T150405Z"),
+ expected: "today",
+ },
+ {
+ name: "tomorrow",
+ input: now.Add(24 * time.Hour).UTC().Format("20060102T150405Z"),
+ expected: "tomorrow",
+ },
+ {
+ name: "yesterday",
+ input: now.Add(-24 * time.Hour).UTC().Format("20060102T150405Z"),
+ expected: "yesterday",
+ },
+ {
+ name: "future",
+ input: now.Add(5 * 24 * time.Hour).UTC().Format("20060102T150405Z"),
+ expected: "5d",
+ },
+ {
+ name: "past",
+ input: now.Add(-3 * 24 * time.Hour).UTC().Format("20060102T150405Z"),
+ expected: "-3d",
+ },
+ {
+ name: "invalid",
+ input: "invalid",
+ expected: "invalid",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := formatDueText(tt.input)
+ if got != tt.expected {
+ t.Errorf("formatDueText() = %v, want %v", got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestValidateTagName(t *testing.T) {
+ tests := []struct {
+ name string
+ tag string
+ wantErr bool
+ }{
+ {
+ name: "valid tag",
+ tag: "work",
+ wantErr: false,
+ },
+ {
+ name: "valid with plus",
+ tag: "+work",
+ wantErr: false,
+ },
+ {
+ name: "valid with minus",
+ tag: "-work",
+ wantErr: false,
+ },
+ {
+ name: "empty tag",
+ tag: "",
+ wantErr: true,
+ },
+ {
+ name: "tag with space",
+ tag: "my tag",
+ wantErr: true,
+ },
+ {
+ name: "tag with tab",
+ tag: "my\ttag",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateTagName(tt.tag)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("validateTagName() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestValidatePriority(t *testing.T) {
+ tests := []struct {
+ name string
+ priority string
+ wantErr bool
+ }{
+ {
+ name: "high",
+ priority: "H",
+ wantErr: false,
+ },
+ {
+ name: "medium",
+ priority: "M",
+ wantErr: false,
+ },
+ {
+ name: "low",
+ priority: "L",
+ wantErr: false,
+ },
+ {
+ name: "empty",
+ priority: "",
+ wantErr: false,
+ },
+ {
+ name: "invalid",
+ priority: "X",
+ wantErr: true,
+ },
+ {
+ name: "lowercase",
+ priority: "h",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validatePriority(tt.priority)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("validatePriority() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestValidateDescription(t *testing.T) {
+ tests := []struct {
+ name string
+ desc string
+ wantErr bool
+ }{
+ {
+ name: "valid description",
+ desc: "Fix the bug",
+ wantErr: false,
+ },
+ {
+ name: "empty description",
+ desc: "",
+ wantErr: true,
+ },
+ {
+ name: "whitespace only",
+ desc: " ",
+ wantErr: true,
+ },
+ {
+ name: "description with whitespace",
+ desc: " Fix the bug ",
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+