diff --git a/component/recurrence_edit.go b/component/recurrence_edit.go new file mode 100644 index 0000000..4a600e5 --- /dev/null +++ b/component/recurrence_edit.go @@ -0,0 +1,247 @@ +package component + +import ( + "fmt" + "strconv" + + "github.com/boolean-maybe/tiki/config" + taskpkg "github.com/boolean-maybe/tiki/task" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// RecurrenceEdit is a single-line recurrence editor with two logical parts. +// Part 1 (frequency): None, Daily, Weekly, Monthly — cycled with Up/Down. +// Part 2 (value): weekday for Weekly, day 1-31 for Monthly — cycled with Up/Down. +// Left/Right switches which part Up/Down controls. The display shows both parts +// with ">" marking the active part (e.g. "Weekly > Monday"). +type RecurrenceEdit struct { + *tview.InputField + + frequencies []string + weekdays []string + freqIndex int // index into frequencies + weekdayIdx int // index into weekdays + day int // 1-31 for monthly + activePart int // 0=frequency, 1=value + onChange func(string) +} + +// NewRecurrenceEdit creates a new recurrence editor. +func NewRecurrenceEdit() *RecurrenceEdit { + inputField := tview.NewInputField() + inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor()) + inputField.SetFieldTextColor(config.GetContentTextColor()) + + re := &RecurrenceEdit{ + InputField: inputField, + frequencies: taskpkg.AllFrequencies(), + weekdays: taskpkg.AllWeekdays(), + freqIndex: 0, // None + weekdayIdx: 0, // Monday + day: 1, + } + + re.updateDisplay() + return re +} + +// SetLabel sets the label displayed before the input field. +func (re *RecurrenceEdit) SetLabel(label string) *RecurrenceEdit { + re.InputField.SetLabel(label) + return re +} + +// SetChangeHandler sets the callback that fires on any value change. +func (re *RecurrenceEdit) SetChangeHandler(handler func(string)) *RecurrenceEdit { + re.onChange = handler + return re +} + +// SetInitialValue populates both parts from a cron string. +func (re *RecurrenceEdit) SetInitialValue(cron string) *RecurrenceEdit { + r := taskpkg.Recurrence(cron) + freq := taskpkg.FrequencyFromRecurrence(r) + + re.freqIndex = 0 + for i, f := range re.frequencies { + if f == string(freq) { + re.freqIndex = i + break + } + } + + switch freq { + case taskpkg.FrequencyWeekly: + if day, ok := taskpkg.WeekdayFromRecurrence(r); ok { + for i, w := range re.weekdays { + if w == day { + re.weekdayIdx = i + break + } + } + } + case taskpkg.FrequencyMonthly: + if d, ok := taskpkg.DayOfMonthFromRecurrence(r); ok { + re.day = d + } + } + + re.activePart = 0 + re.updateDisplay() + return re +} + +// GetValue assembles the current cron expression from the editor state. +func (re *RecurrenceEdit) GetValue() string { + freq := taskpkg.RecurrenceFrequency(re.frequencies[re.freqIndex]) + + switch freq { + case taskpkg.FrequencyDaily: + return string(taskpkg.RecurrenceDaily) + case taskpkg.FrequencyWeekly: + return string(taskpkg.WeeklyRecurrence(re.weekdays[re.weekdayIdx])) + case taskpkg.FrequencyMonthly: + return string(taskpkg.MonthlyRecurrence(re.day)) + default: + return string(taskpkg.RecurrenceNone) + } +} + +// InputHandler handles keyboard input for the recurrence editor. +func (re *RecurrenceEdit) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + return func(event *tcell.EventKey, _ func(p tview.Primitive)) { + switch event.Key() { + case tcell.KeyLeft: + re.activePart = 0 + re.updateDisplay() + case tcell.KeyRight: + if re.hasValuePart() { + re.activePart = 1 + re.updateDisplay() + } + case tcell.KeyUp: + re.cyclePrev() + case tcell.KeyDown: + re.cycleNext() + } + } +} + +// CyclePrev cycles the active part's value backward. +func (re *RecurrenceEdit) CyclePrev() { + re.cyclePrev() +} + +// CycleNext cycles the active part's value forward. +func (re *RecurrenceEdit) CycleNext() { + re.cycleNext() +} + +// MovePartLeft moves the active part to frequency (part 0). +func (re *RecurrenceEdit) MovePartLeft() { + re.activePart = 0 + re.updateDisplay() +} + +// MovePartRight moves the active part to value (part 1), if a value part exists. +func (re *RecurrenceEdit) MovePartRight() { + if re.hasValuePart() { + re.activePart = 1 + re.updateDisplay() + } +} + +func (re *RecurrenceEdit) cyclePrev() { + if re.activePart == 0 { + re.freqIndex-- + if re.freqIndex < 0 { + re.freqIndex = len(re.frequencies) - 1 + } + re.resetValueDefaults() + } else { + re.cycleValuePrev() + } + re.updateDisplay() + re.emitChange() +} + +func (re *RecurrenceEdit) cycleNext() { + if re.activePart == 0 { + re.freqIndex = (re.freqIndex + 1) % len(re.frequencies) + re.resetValueDefaults() + } else { + re.cycleValueNext() + } + re.updateDisplay() + re.emitChange() +} + +func (re *RecurrenceEdit) cycleValuePrev() { + freq := taskpkg.RecurrenceFrequency(re.frequencies[re.freqIndex]) + switch freq { + case taskpkg.FrequencyWeekly: + re.weekdayIdx-- + if re.weekdayIdx < 0 { + re.weekdayIdx = len(re.weekdays) - 1 + } + case taskpkg.FrequencyMonthly: + re.day-- + if re.day < 1 { + re.day = 31 + } + } +} + +func (re *RecurrenceEdit) cycleValueNext() { + freq := taskpkg.RecurrenceFrequency(re.frequencies[re.freqIndex]) + switch freq { + case taskpkg.FrequencyWeekly: + re.weekdayIdx = (re.weekdayIdx + 1) % len(re.weekdays) + case taskpkg.FrequencyMonthly: + re.day++ + if re.day > 31 { + re.day = 1 + } + } +} + +func (re *RecurrenceEdit) resetValueDefaults() { + re.weekdayIdx = 0 + re.day = 1 + if !re.hasValuePart() { + re.activePart = 0 + } +} + +func (re *RecurrenceEdit) hasValuePart() bool { + freq := taskpkg.RecurrenceFrequency(re.frequencies[re.freqIndex]) + return freq == taskpkg.FrequencyWeekly || freq == taskpkg.FrequencyMonthly +} + +func (re *RecurrenceEdit) updateDisplay() { + freq := taskpkg.RecurrenceFrequency(re.frequencies[re.freqIndex]) + sep := " : " + if re.activePart == 1 { + sep = " > " + } + + var text string + switch freq { + case taskpkg.FrequencyWeekly: + text = fmt.Sprintf("Weekly%s%s", sep, re.weekdays[re.weekdayIdx]) + case taskpkg.FrequencyMonthly: + text = fmt.Sprintf("Monthly%s%s", sep, strconv.Itoa(re.day)+taskpkg.OrdinalSuffix(re.day)) + default: + text = re.frequencies[re.freqIndex] + } + + re.SetText(text) +} + +func (re *RecurrenceEdit) emitChange() { + if re.onChange != nil { + re.onChange(re.GetValue()) + } +} diff --git a/component/recurrence_edit_test.go b/component/recurrence_edit_test.go new file mode 100644 index 0000000..ce03ee7 --- /dev/null +++ b/component/recurrence_edit_test.go @@ -0,0 +1,192 @@ +package component + +import ( + "testing" + + taskpkg "github.com/boolean-maybe/tiki/task" +) + +func TestRecurrenceEdit_DefaultIsNone(t *testing.T) { + re := NewRecurrenceEdit() + if got := re.GetValue(); got != "" { + t.Errorf("expected empty cron for None, got %q", got) + } + if got := re.GetText(); got != "None" { + t.Errorf("expected display 'None', got %q", got) + } +} + +func TestRecurrenceEdit_SetInitialValue_Daily(t *testing.T) { + re := NewRecurrenceEdit() + re.SetInitialValue(string(taskpkg.RecurrenceDaily)) + + if got := re.GetValue(); got != string(taskpkg.RecurrenceDaily) { + t.Errorf("expected %q, got %q", taskpkg.RecurrenceDaily, got) + } + if got := re.GetText(); got != "Daily" { + t.Errorf("expected display 'Daily', got %q", got) + } +} + +func TestRecurrenceEdit_SetInitialValue_Weekly(t *testing.T) { + re := NewRecurrenceEdit() + re.SetInitialValue("0 0 * * FRI") + + if got := re.GetValue(); got != "0 0 * * FRI" { + t.Errorf("expected '0 0 * * FRI', got %q", got) + } + if got := re.GetText(); got != "Weekly : Friday" { + t.Errorf("expected display 'Weekly : Friday', got %q", got) + } +} + +func TestRecurrenceEdit_SetInitialValue_Monthly(t *testing.T) { + re := NewRecurrenceEdit() + re.SetInitialValue("0 0 15 * *") + + if got := re.GetValue(); got != "0 0 15 * *" { + t.Errorf("expected '0 0 15 * *', got %q", got) + } + if got := re.GetText(); got != "Monthly : 15th" { + t.Errorf("expected display 'Monthly : 15th', got %q", got) + } +} + +func TestRecurrenceEdit_SetInitialValue_None(t *testing.T) { + re := NewRecurrenceEdit() + re.SetInitialValue("") + + if got := re.GetValue(); got != "" { + t.Errorf("expected empty, got %q", got) + } +} + +func TestRecurrenceEdit_FrequencySwitchResetsValue(t *testing.T) { + re := NewRecurrenceEdit() + re.SetInitialValue("0 0 * * FRI") // Weekly on Friday + + // cycle frequency forward: Weekly → Monthly + re.CycleNext() + + // value should reset to day 1 (default) + if got := re.GetValue(); got != "0 0 1 * *" { + t.Errorf("expected '0 0 1 * *' after switch to Monthly, got %q", got) + } + + // cycle again: Monthly → None (wraps) + re.CycleNext() + if got := re.GetValue(); got != "" { + t.Errorf("expected empty after switch to None, got %q", got) + } +} + +func TestRecurrenceEdit_ChangeHandlerFires(t *testing.T) { + re := NewRecurrenceEdit() + re.SetInitialValue(string(taskpkg.RecurrenceDaily)) + + var lastValue string + callCount := 0 + re.SetChangeHandler(func(v string) { + lastValue = v + callCount++ + }) + + // cycle frequency: Daily → Weekly (defaults to Monday) + re.CycleNext() + + if callCount != 1 { + t.Errorf("expected 1 call, got %d", callCount) + } + if lastValue != "0 0 * * MON" { + t.Errorf("expected '0 0 * * MON', got %q", lastValue) + } +} + +func TestRecurrenceEdit_MovePartRight(t *testing.T) { + re := NewRecurrenceEdit() + re.SetInitialValue("0 0 * * MON") // Weekly — has value part + + // move to value part + re.MovePartRight() + if re.activePart != 1 { + t.Errorf("expected activePart=1, got %d", re.activePart) + } + if got := re.GetText(); got != "Weekly > Monday" { + t.Errorf("expected 'Weekly > Monday', got %q", got) + } +} + +func TestRecurrenceEdit_MovePartLeft(t *testing.T) { + re := NewRecurrenceEdit() + re.SetInitialValue("0 0 * * MON") + + // move to value part first, then back to frequency + re.MovePartRight() + re.MovePartLeft() + if re.activePart != 0 { + t.Errorf("expected activePart=0, got %d", re.activePart) + } + if got := re.GetText(); got != "Weekly : Monday" { + t.Errorf("expected 'Weekly : Monday', got %q", got) + } +} + +func TestRecurrenceEdit_MovePartRightNoopForNone(t *testing.T) { + re := NewRecurrenceEdit() + re.SetInitialValue("") // None — no value part + + re.MovePartRight() + if re.activePart != 0 { + t.Errorf("expected activePart=0 (no value part to move to), got %d", re.activePart) + } +} + +func TestRecurrenceEdit_CycleValuePart(t *testing.T) { + re := NewRecurrenceEdit() + re.SetInitialValue("0 0 15 * *") // Monthly on 15th + + // switch to value part + re.MovePartRight() + + // CycleNext increments day + re.CycleNext() + if got := re.GetValue(); got != "0 0 16 * *" { + t.Errorf("expected '0 0 16 * *' after CycleNext, got %q", got) + } + + // CyclePrev decrements + re.CyclePrev() + if got := re.GetValue(); got != "0 0 15 * *" { + t.Errorf("expected '0 0 15 * *' after CyclePrev, got %q", got) + } +} + +func TestRecurrenceEdit_CycleWeekdayValuePart(t *testing.T) { + re := NewRecurrenceEdit() + re.SetInitialValue("0 0 * * MON") + + re.MovePartRight() + re.CycleNext() + if got := re.GetValue(); got != "0 0 * * TUE" { + t.Errorf("expected '0 0 * * TUE', got %q", got) + } +} + +func TestRecurrenceEdit_MonthlyDayWraps(t *testing.T) { + re := NewRecurrenceEdit() + re.SetInitialValue("0 0 31 * *") + + re.MovePartRight() + re.CycleNext() + if re.day != 1 { + t.Errorf("expected day=1 after wrap, got %d", re.day) + } +} + +func TestRecurrenceEdit_SetLabel(t *testing.T) { + re := NewRecurrenceEdit() + re.SetLabel("Recurrence: ") + if got := re.GetLabel(); got != "Recurrence: " { + t.Errorf("expected label 'Recurrence: ', got %q", got) + } +} diff --git a/controller/actions.go b/controller/actions.go index d4471bc..57612e1 100644 --- a/controller/actions.go +++ b/controller/actions.go @@ -345,6 +345,16 @@ func TaskEditDueActions() *ActionRegistry { return r } +// TaskEditRecurrenceActions returns actions available when editing the recurrence field +func TaskEditRecurrenceActions() *ActionRegistry { + r := CommonFieldNavigationActions() + r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true}) + r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true}) + r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "← Part", ShowInHeader: true}) + r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "Part →", ShowInHeader: true}) + return r +} + // TaskEditDescriptionActions returns actions available when editing the description field func TaskEditDescriptionActions() *ActionRegistry { r := NewActionRegistry() @@ -377,6 +387,8 @@ func GetActionsForField(field model.EditField) *ActionRegistry { return TaskEditPointsActions() case model.EditFieldDue: return TaskEditDueActions() + case model.EditFieldRecurrence: + return TaskEditRecurrenceActions() case model.EditFieldDescription: return TaskEditDescriptionActions() default: diff --git a/controller/interfaces.go b/controller/interfaces.go index 0722fdb..ef73744 100644 --- a/controller/interfaces.go +++ b/controller/interfaces.go @@ -239,6 +239,21 @@ type DueEditableView interface { SetDueSaveHandler(handler func(string)) } +// RecurrenceEditableView is a view that supports recurrence editing functionality +type RecurrenceEditableView interface { + View + + // SetRecurrenceSaveHandler sets the callback for when recurrence is saved + SetRecurrenceSaveHandler(handler func(string)) +} + +// RecurrencePartNavigable is a view that supports Left/Right navigation between +// the two logical parts of a recurrence field (frequency and value). +type RecurrencePartNavigable interface { + MoveRecurrencePartLeft() bool + MoveRecurrencePartRight() bool +} + // StatsProvider is a view that provides statistics for the header type StatsProvider interface { // GetStats returns stats to display in the header for this view diff --git a/controller/task_detail.go b/controller/task_detail.go index b29e1a2..5fc18d0 100644 --- a/controller/task_detail.go +++ b/controller/task_detail.go @@ -391,6 +391,19 @@ func (tc *TaskController) SaveDue(dateStr string) bool { }) } +// SaveRecurrence saves the new recurrence cron expression to the current task. +// Returns true if the recurrence was successfully updated, false otherwise. +func (tc *TaskController) SaveRecurrence(cron string) bool { + r := taskpkg.Recurrence(cron) + if !taskpkg.IsValidRecurrence(r) { + slog.Warn("invalid recurrence", "cron", cron) + return false + } + return tc.updateTaskField(func(t *taskpkg.Task) { + t.Recurrence = r + }) +} + func (tc *TaskController) handleCloneTask() bool { // TODO: trigger task clone flow from detail view return true diff --git a/controller/task_edit_coordinator.go b/controller/task_edit_coordinator.go index 8beccac..5d14d13 100644 --- a/controller/task_edit_coordinator.go +++ b/controller/task_edit_coordinator.go @@ -61,6 +61,16 @@ func (c *TaskEditCoordinator) HandleKey(activeView View, event *tcell.EventKey) return false } return c.FocusPrevField(activeView) + case tcell.KeyLeft: + if nav, ok := activeView.(RecurrencePartNavigable); ok { + return nav.MoveRecurrencePartLeft() + } + return false + case tcell.KeyRight: + if nav, ok := activeView.(RecurrencePartNavigable); ok { + return nav.MoveRecurrencePartRight() + } + return false case tcell.KeyEscape: return c.CancelAndClose() case tcell.KeyUp: @@ -239,6 +249,12 @@ func (c *TaskEditCoordinator) prepareView(activeView View, focus model.EditField c.taskController.SaveDue(dateStr) }) } + + if recurrenceEditableView, ok := activeView.(RecurrenceEditableView); ok { + recurrenceEditableView.SetRecurrenceSaveHandler(func(cron string) { + c.taskController.SaveRecurrence(cron) + }) + } } // In desc-only mode, skip title focus entirely — go straight to description diff --git a/model/edit_field.go b/model/edit_field.go index 2d2fb19..c2be576 100644 --- a/model/edit_field.go +++ b/model/edit_field.go @@ -11,6 +11,7 @@ const ( EditFieldAssignee EditField = "assignee" EditFieldPoints EditField = "points" EditFieldDue EditField = "due" + EditFieldRecurrence EditField = "recurrence" EditFieldDescription EditField = "description" ) @@ -23,6 +24,7 @@ var fieldOrder = []EditField{ EditFieldPoints, EditFieldAssignee, EditFieldDue, + EditFieldRecurrence, EditFieldDescription, } @@ -59,7 +61,7 @@ func PrevField(current EditField) EditField { // IsEditableField returns true if the field can be edited (not just viewed) func IsEditableField(field EditField) bool { switch field { - case EditFieldTitle, EditFieldPriority, EditFieldAssignee, EditFieldPoints, EditFieldDue, EditFieldDescription: + case EditFieldTitle, EditFieldPriority, EditFieldAssignee, EditFieldPoints, EditFieldDue, EditFieldRecurrence, EditFieldDescription: return true default: // Status is read-only for now @@ -84,6 +86,8 @@ func FieldLabel(field EditField) string { return "Story Points" case EditFieldDue: return "Due" + case EditFieldRecurrence: + return "Recurrence" case EditFieldDescription: return "Description" default: diff --git a/model/edit_field_test.go b/model/edit_field_test.go index f8b269a..b83b606 100644 --- a/model/edit_field_test.go +++ b/model/edit_field_test.go @@ -14,7 +14,8 @@ func TestNextField(t *testing.T) { {"Priority to Points", EditFieldPriority, EditFieldPoints}, {"Points to Assignee", EditFieldPoints, EditFieldAssignee}, {"Assignee to Due", EditFieldAssignee, EditFieldDue}, - {"Due to Description", EditFieldDue, EditFieldDescription}, + {"Due to Recurrence", EditFieldDue, EditFieldRecurrence}, + {"Recurrence to Description", EditFieldRecurrence, EditFieldDescription}, {"Description stays at Description (no wrap)", EditFieldDescription, EditFieldDescription}, {"Unknown field defaults to Title", EditField("unknown"), EditFieldTitle}, } @@ -42,7 +43,8 @@ func TestPrevField(t *testing.T) { {"Points to Priority", EditFieldPoints, EditFieldPriority}, {"Assignee to Points", EditFieldAssignee, EditFieldPoints}, {"Due to Assignee", EditFieldDue, EditFieldAssignee}, - {"Description to Due", EditFieldDescription, EditFieldDue}, + {"Recurrence to Due", EditFieldRecurrence, EditFieldDue}, + {"Description to Recurrence", EditFieldDescription, EditFieldRecurrence}, {"Unknown field defaults to Title", EditField("unknown"), EditFieldTitle}, } @@ -66,6 +68,7 @@ func TestFieldCycling(t *testing.T) { EditFieldPoints, EditFieldAssignee, EditFieldDue, + EditFieldRecurrence, EditFieldDescription, EditFieldDescription, // stays at end } @@ -80,6 +83,7 @@ func TestFieldCycling(t *testing.T) { // Test complete backward navigation (stops at beginning, no wrap) field = EditFieldDescription expectedOrderReverse := []EditField{ + EditFieldRecurrence, EditFieldDue, EditFieldAssignee, EditFieldPoints, @@ -110,6 +114,7 @@ func TestIsEditableField(t *testing.T) { {"Assignee is editable", EditFieldAssignee, true}, {"Points is editable", EditFieldPoints, true}, {"Due is editable", EditFieldDue, true}, + {"Recurrence is editable", EditFieldRecurrence, true}, {"Description is editable", EditFieldDescription, true}, } @@ -135,6 +140,7 @@ func TestFieldLabel(t *testing.T) { {"Assignee label", EditFieldAssignee, "Assignee"}, {"Points label", EditFieldPoints, "Story Points"}, {"Due label", EditFieldDue, "Due"}, + {"Recurrence label", EditFieldRecurrence, "Recurrence"}, {"Description label", EditFieldDescription, "Description"}, } diff --git a/task/recurrence.go b/task/recurrence.go index 4cd9eb4..32248e7 100644 --- a/task/recurrence.go +++ b/task/recurrence.go @@ -1,7 +1,10 @@ package task import ( + "fmt" "log/slog" + "regexp" + "strconv" "strings" "gopkg.in/yaml.v3" @@ -33,7 +36,135 @@ var knownRecurrences = []recurrenceInfo{ {"0 0 * * FRI", "Weekly on Friday"}, {"0 0 * * SAT", "Weekly on Saturday"}, {"0 0 * * SUN", "Weekly on Sunday"}, - {RecurrenceMonthly, "Monthly"}, + {RecurrenceMonthly, "Monthly on the 1st"}, +} + +// RecurrenceFrequency represents a high-level recurrence category for the UI editor. +type RecurrenceFrequency string + +const ( + FrequencyNone RecurrenceFrequency = "None" + FrequencyDaily RecurrenceFrequency = "Daily" + FrequencyWeekly RecurrenceFrequency = "Weekly" + FrequencyMonthly RecurrenceFrequency = "Monthly" +) + +// AllFrequencies returns the ordered list of frequencies for the UI. +func AllFrequencies() []string { + return []string{ + string(FrequencyNone), + string(FrequencyDaily), + string(FrequencyWeekly), + string(FrequencyMonthly), + } +} + +// monthlyPattern matches cron expressions like "0 0 15 * *" +var monthlyPattern = regexp.MustCompile(`^0 0 (\d{1,2}) \* \*$`) + +// OrdinalSuffix returns the ordinal suffix for a number (st, nd, rd, th). +func OrdinalSuffix(n int) string { + if n >= 11 && n <= 13 { + return "th" + } + switch n % 10 { + case 1: + return "st" + case 2: + return "nd" + case 3: + return "rd" + default: + return "th" + } +} + +// MonthlyRecurrence creates a monthly cron expression for the given day (1-31). +func MonthlyRecurrence(day int) Recurrence { + if day < 1 || day > 31 { + return RecurrenceNone + } + return Recurrence(fmt.Sprintf("0 0 %d * *", day)) +} + +// IsMonthlyRecurrence checks if a recurrence is a monthly pattern. +// Returns the day of month and true if it matches "0 0 N * *". +func IsMonthlyRecurrence(r Recurrence) (int, bool) { + m := monthlyPattern.FindStringSubmatch(string(r)) + if m == nil { + return 0, false + } + day, err := strconv.Atoi(m[1]) + if err != nil || day < 1 || day > 31 { + return 0, false + } + return day, true +} + +// MonthlyDisplay returns a human-readable string like "Monthly on the 15th". +func MonthlyDisplay(day int) string { + return fmt.Sprintf("Monthly on the %d%s", day, OrdinalSuffix(day)) +} + +// FrequencyFromRecurrence extracts the high-level frequency from a cron expression. +func FrequencyFromRecurrence(r Recurrence) RecurrenceFrequency { + if r == RecurrenceNone { + return FrequencyNone + } + if r == RecurrenceDaily { + return FrequencyDaily + } + if _, ok := WeekdayFromRecurrence(r); ok { + return FrequencyWeekly + } + if _, ok := IsMonthlyRecurrence(r); ok { + return FrequencyMonthly + } + return FrequencyNone +} + +// weekdayCronToName maps cron weekday abbreviations to full day names. +var weekdayCronToName = map[string]string{ + "MON": "Monday", "TUE": "Tuesday", "WED": "Wednesday", + "THU": "Thursday", "FRI": "Friday", "SAT": "Saturday", "SUN": "Sunday", +} + +// weekdayNameToCron maps full day names back to cron abbreviations. +var weekdayNameToCron = map[string]string{ + "Monday": "MON", "Tuesday": "TUE", "Wednesday": "WED", + "Thursday": "THU", "Friday": "FRI", "Saturday": "SAT", "Sunday": "SUN", +} + +// AllWeekdays returns weekday names in order for the UI. +func AllWeekdays() []string { + return []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"} +} + +// weeklyPattern matches cron expressions like "0 0 * * MON" +var weeklyPattern = regexp.MustCompile(`^0 0 \* \* ([A-Z]{3})$`) + +// WeekdayFromRecurrence extracts the weekday name from a weekly cron expression. +func WeekdayFromRecurrence(r Recurrence) (string, bool) { + m := weeklyPattern.FindStringSubmatch(string(r)) + if m == nil { + return "", false + } + name, ok := weekdayCronToName[m[1]] + return name, ok +} + +// DayOfMonthFromRecurrence extracts the day (1-31) from a monthly cron expression. +func DayOfMonthFromRecurrence(r Recurrence) (int, bool) { + return IsMonthlyRecurrence(r) +} + +// WeeklyRecurrence creates a weekly cron expression for the given day name. +func WeeklyRecurrence(dayName string) Recurrence { + abbrev, ok := weekdayNameToCron[dayName] + if !ok { + return RecurrenceNone + } + return Recurrence("0 0 * * " + abbrev) } // built at init from knownRecurrences @@ -103,7 +234,7 @@ func (r RecurrenceValue) ToRecurrence() Recurrence { return r.Value } -// ParseRecurrence validates a cron string against known patterns. +// ParseRecurrence validates a cron string against known patterns or monthly pattern. func ParseRecurrence(s string) (Recurrence, bool) { normalized := Recurrence(strings.ToLower(strings.TrimSpace(s))) // accept both lowercase and original casing @@ -112,6 +243,11 @@ func ParseRecurrence(s string) (Recurrence, bool) { return r.cron, true } } + // try monthly pattern (e.g. "0 0 15 * *") + candidate := Recurrence(strings.TrimSpace(s)) + if day, ok := IsMonthlyRecurrence(candidate); ok { + return MonthlyRecurrence(day), true + } return RecurrenceNone, false } @@ -120,6 +256,9 @@ func RecurrenceDisplay(r Recurrence) string { if d, ok := cronToDisplay[r]; ok { return d } + if day, ok := IsMonthlyRecurrence(r); ok { + return MonthlyDisplay(day) + } return "None" } @@ -140,7 +279,11 @@ func AllRecurrenceDisplayValues() []string { return result } -// IsValidRecurrence returns true if the recurrence is empty or matches a known pattern. +// IsValidRecurrence returns true if the recurrence is empty or matches a known or monthly pattern. func IsValidRecurrence(r Recurrence) bool { - return validCronSet[r] + if validCronSet[r] { + return true + } + _, ok := IsMonthlyRecurrence(r) + return ok } diff --git a/task/recurrence_test.go b/task/recurrence_test.go index dce0d7b..d8b46a4 100644 --- a/task/recurrence_test.go +++ b/task/recurrence_test.go @@ -242,7 +242,7 @@ func TestRecurrenceDisplay(t *testing.T) { {"0 0 * * FRI", "Weekly on Friday"}, {"0 0 * * SAT", "Weekly on Saturday"}, {"0 0 * * SUN", "Weekly on Sunday"}, - {RecurrenceMonthly, "Monthly"}, + {RecurrenceMonthly, "Monthly on the 1st"}, {"unknown", "None"}, } @@ -265,7 +265,8 @@ func TestRecurrenceFromDisplay(t *testing.T) { {"Daily", RecurrenceDaily}, {"Weekly on Monday", "0 0 * * MON"}, {"weekly on monday", "0 0 * * MON"}, - {"Monthly", RecurrenceMonthly}, + {"Monthly on the 1st", RecurrenceMonthly}, + {"monthly on the 1st", RecurrenceMonthly}, {"unknown", RecurrenceNone}, {"", RecurrenceNone}, } @@ -288,8 +289,8 @@ func TestAllRecurrenceDisplayValues(t *testing.T) { if values[0] != "None" { t.Errorf("first value should be None, got %q", values[0]) } - if values[len(values)-1] != "Monthly" { - t.Errorf("last value should be Monthly, got %q", values[len(values)-1]) + if values[len(values)-1] != "Monthly on the 1st" { + t.Errorf("last value should be 'Monthly on the 1st', got %q", values[len(values)-1]) } // every display value must round-trip through RecurrenceFromDisplay → RecurrenceDisplay for _, v := range values { @@ -346,6 +347,213 @@ func TestRecurrenceValue_ToRecurrence(t *testing.T) { }) } +func TestOrdinalSuffix(t *testing.T) { + tests := []struct { + n int + want string + }{ + {1, "st"}, {2, "nd"}, {3, "rd"}, {4, "th"}, + {11, "th"}, {12, "th"}, {13, "th"}, + {21, "st"}, {22, "nd"}, {23, "rd"}, + {31, "st"}, + } + for _, tt := range tests { + got := OrdinalSuffix(tt.n) + if got != tt.want { + t.Errorf("OrdinalSuffix(%d) = %q, want %q", tt.n, got, tt.want) + } + } +} + +func TestMonthlyRecurrence(t *testing.T) { + tests := []struct { + day int + want Recurrence + }{ + {1, "0 0 1 * *"}, + {15, "0 0 15 * *"}, + {31, "0 0 31 * *"}, + {0, RecurrenceNone}, + {32, RecurrenceNone}, + } + for _, tt := range tests { + got := MonthlyRecurrence(tt.day) + if got != tt.want { + t.Errorf("MonthlyRecurrence(%d) = %q, want %q", tt.day, got, tt.want) + } + } +} + +func TestIsMonthlyRecurrence(t *testing.T) { + tests := []struct { + r Recurrence + wantDay int + wantOk bool + }{ + {"0 0 1 * *", 1, true}, + {"0 0 15 * *", 15, true}, + {"0 0 31 * *", 31, true}, + {"0 0 * * *", 0, false}, // daily, not monthly + {"0 0 * * MON", 0, false}, // weekly + {"0 0 0 * *", 0, false}, // day 0 is invalid + {"0 0 32 * *", 0, false}, // day 32 is invalid + } + for _, tt := range tests { + day, ok := IsMonthlyRecurrence(tt.r) + if ok != tt.wantOk || day != tt.wantDay { + t.Errorf("IsMonthlyRecurrence(%q) = (%d, %v), want (%d, %v)", tt.r, day, ok, tt.wantDay, tt.wantOk) + } + } +} + +func TestMonthlyDisplay(t *testing.T) { + tests := []struct { + day int + want string + }{ + {1, "Monthly on the 1st"}, + {2, "Monthly on the 2nd"}, + {3, "Monthly on the 3rd"}, + {4, "Monthly on the 4th"}, + {15, "Monthly on the 15th"}, + {21, "Monthly on the 21st"}, + {31, "Monthly on the 31st"}, + } + for _, tt := range tests { + got := MonthlyDisplay(tt.day) + if got != tt.want { + t.Errorf("MonthlyDisplay(%d) = %q, want %q", tt.day, got, tt.want) + } + } +} + +func TestFrequencyFromRecurrence(t *testing.T) { + tests := []struct { + r Recurrence + want RecurrenceFrequency + }{ + {RecurrenceNone, FrequencyNone}, + {RecurrenceDaily, FrequencyDaily}, + {"0 0 * * MON", FrequencyWeekly}, + {"0 0 * * FRI", FrequencyWeekly}, + {RecurrenceMonthly, FrequencyMonthly}, + {"0 0 15 * *", FrequencyMonthly}, + {"bogus", FrequencyNone}, + } + for _, tt := range tests { + got := FrequencyFromRecurrence(tt.r) + if got != tt.want { + t.Errorf("FrequencyFromRecurrence(%q) = %q, want %q", tt.r, got, tt.want) + } + } +} + +func TestWeekdayFromRecurrence(t *testing.T) { + tests := []struct { + r Recurrence + want string + wantOk bool + }{ + {"0 0 * * MON", "Monday", true}, + {"0 0 * * SUN", "Sunday", true}, + {"0 0 * * *", "", false}, + {"0 0 1 * *", "", false}, + } + for _, tt := range tests { + got, ok := WeekdayFromRecurrence(tt.r) + if ok != tt.wantOk || got != tt.want { + t.Errorf("WeekdayFromRecurrence(%q) = (%q, %v), want (%q, %v)", tt.r, got, ok, tt.want, tt.wantOk) + } + } +} + +func TestDayOfMonthFromRecurrence(t *testing.T) { + tests := []struct { + r Recurrence + want int + wantOk bool + }{ + {"0 0 15 * *", 15, true}, + {"0 0 * * MON", 0, false}, + } + for _, tt := range tests { + got, ok := DayOfMonthFromRecurrence(tt.r) + if ok != tt.wantOk || got != tt.want { + t.Errorf("DayOfMonthFromRecurrence(%q) = (%d, %v), want (%d, %v)", tt.r, got, ok, tt.want, tt.wantOk) + } + } +} + +func TestRecurrenceDisplay_Monthly(t *testing.T) { + tests := []struct { + input Recurrence + want string + }{ + {"0 0 15 * *", "Monthly on the 15th"}, + {"0 0 2 * *", "Monthly on the 2nd"}, + {"0 0 31 * *", "Monthly on the 31st"}, + } + for _, tt := range tests { + got := RecurrenceDisplay(tt.input) + if got != tt.want { + t.Errorf("RecurrenceDisplay(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestIsValidRecurrence_Monthly(t *testing.T) { + tests := []struct { + input Recurrence + want bool + }{ + {"0 0 15 * *", true}, + {"0 0 31 * *", true}, + {"0 0 0 * *", false}, + {"0 0 32 * *", false}, + } + for _, tt := range tests { + got := IsValidRecurrence(tt.input) + if got != tt.want { + t.Errorf("IsValidRecurrence(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestParseRecurrence_Monthly(t *testing.T) { + tests := []struct { + input string + want Recurrence + ok bool + }{ + {"0 0 15 * *", "0 0 15 * *", true}, + {"0 0 31 * *", "0 0 31 * *", true}, + {"0 0 0 * *", RecurrenceNone, false}, + } + for _, tt := range tests { + got, ok := ParseRecurrence(tt.input) + if ok != tt.ok || got != tt.want { + t.Errorf("ParseRecurrence(%q) = (%q, %v), want (%q, %v)", tt.input, got, ok, tt.want, tt.ok) + } + } +} + +func TestWeeklyRecurrence(t *testing.T) { + tests := []struct { + dayName string + want Recurrence + }{ + {"Monday", "0 0 * * MON"}, + {"Sunday", "0 0 * * SUN"}, + {"Invalid", RecurrenceNone}, + } + for _, tt := range tests { + got := WeeklyRecurrence(tt.dayName) + if got != tt.want { + t.Errorf("WeeklyRecurrence(%q) = %q, want %q", tt.dayName, got, tt.want) + } + } +} + func TestRecurrenceValue_IsZero(t *testing.T) { tests := []struct { name string diff --git a/view/taskdetail/metadata_layout.go b/view/taskdetail/metadata_layout.go index e515a29..337c00e 100644 --- a/view/taskdetail/metadata_layout.go +++ b/view/taskdetail/metadata_layout.go @@ -5,6 +5,24 @@ package taskdetail // integers in, plan out — so it can be tested and swapped independently. const maxLeftSideGap = 8 +const minBridgeGap = 3 +const maxBridgeGap = 12 + +// Layout algorithm overview: +// +// Sections are divided into two groups: core (Status, People, Due) and optional +// (Tags, DependsOn, Blocks). Optional sections live on the right side and are +// shed when the terminal is too narrow, in order: Tags → Blocks → DependsOn. +// +// Gap distribution works differently for left vs right: +// - Left-side gaps expand equally, each capped at maxLeftSideGap. +// - A "bridge gap" separates the last core section from the first optional +// section, bounded by minBridgeGap..maxBridgeGap. +// - Right-side sections expand their widths to absorb remaining space. +// +// When only core sections remain (no right side), gaps are capped equally at +// maxLeftSideGap and any leftover is unallocated — sections stay left-aligned +// rather than stretching across the full width. // SectionID identifies a metadata section in left-to-right display order. type SectionID int @@ -129,7 +147,8 @@ func removeSection(sections []SectionInput, id SectionID) []SectionInput { // 1. All sections start at their declared min width, all gaps at 1. // 2. Remaining free space is split: left-side gaps expand (capped at // maxLeftSideGap); then right-side sections expand equally. -// 3. Any leftover from the left-gap cap goes to the bridge gap. +// 3. Any leftover goes to the bridge gap. When no right-side sections exist, +// leftover is unallocated (sections stay left-aligned). func distributeSpace(active []SectionInput, availableWidth int) LayoutPlan { numGaps := len(active) - 1 bridgeIdx := findBridgeGap(active) @@ -168,7 +187,13 @@ func distributeSpace(active []SectionInput, availableWidth int) LayoutPlan { return LayoutPlan{Sections: planned, Gaps: gaps} } - // step 1: expand left-side gaps (capped at maxLeftSideGap) + // step 0: reserve minimum bridge gap before left-gap expansion + if bridgeIdx >= 0 && free >= (minBridgeGap-1) { + gaps[bridgeIdx] = minBridgeGap + free = availableWidth - totalUsed() + } + + // step 1: expand left-side gaps equally (each capped at maxLeftSideGap) leftGapCount := bridgeIdx if bridgeIdx < 0 { leftGapCount = numGaps @@ -194,13 +219,17 @@ func distributeSpace(active []SectionInput, availableWidth int) LayoutPlan { free = availableWidth - totalUsed() } - // step 3: any rounding leftover goes to bridge gap (or last gap) - if free > 0 { - sinkIdx := numGaps - 1 - if bridgeIdx >= 0 { - sinkIdx = bridgeIdx - } - gaps[sinkIdx] += free + // step 3: any rounding leftover goes to bridge gap; when no right-side + // sections exist, leftover is unallocated (sections stay left-aligned) + if free > 0 && bridgeIdx >= 0 { + gaps[bridgeIdx] += free + } + + // step 4: cap bridge gap — overflow goes to last section width + if bridgeIdx >= 0 && gaps[bridgeIdx] > maxBridgeGap { + overflow := gaps[bridgeIdx] - maxBridgeGap + gaps[bridgeIdx] = maxBridgeGap + planned[len(planned)-1].Width += overflow } return LayoutPlan{Sections: planned, Gaps: gaps} diff --git a/view/taskdetail/metadata_layout_test.go b/view/taskdetail/metadata_layout_test.go index 1badc82..1c15701 100644 --- a/view/taskdetail/metadata_layout_test.go +++ b/view/taskdetail/metadata_layout_test.go @@ -29,15 +29,14 @@ func TestAllSectionsFit(t *testing.T) { t.Fatalf("expected 5 gaps, got %d", len(plan.Gaps)) } - // left-side gaps capped at 8, bridge+right gaps stay at 1 - expectedGaps := []int{8, 8, 1, 1, 1} + // left-side gaps capped at 8, bridge gap reserved at minBridgeGap, right gaps stay at 1 + expectedGaps := []int{8, 8, 3, 1, 1} for i, g := range plan.Gaps { if g != expectedGaps[i] { t.Errorf("gap[%d] = %d, want %d", i, g, expectedGaps[i]) } } - // right-side sections expand: (190-90-19)/3 = 27, remainder distributed verifyTotalWidth(t, plan, 190) } @@ -50,7 +49,7 @@ func TestAllSectionsFit_RemainderInBridgeGap(t *testing.T) { t.Fatalf("expected 6 sections, got %d", len(plan.Sections)) } - expectedGaps := []int{7, 7, 1, 1, 1} + expectedGaps := []int{6, 6, 3, 1, 1} for i, g := range plan.Gaps { if g != expectedGaps[i] { t.Errorf("gap[%d] = %d, want %d", i, g, expectedGaps[i]) @@ -111,14 +110,13 @@ func TestAllRightSideHidden(t *testing.T) { } // free = 95-92 = 3, 2 left gaps, perGap=min(3/2,7)=1, gaps=[2, 2] - // leftover 1 goes to last gap → [2, 3] - expected := []int{2, 3} + // leftover 1 capped at maxLeftSideGap → last gap stays at 2 + expected := []int{2, 2} for i, g := range plan.Gaps { if g != expected[i] { t.Errorf("gap[%d] = %d, want %d", i, g, expected[i]) } } - verifyTotalWidth(t, plan, 95) } func TestDueGroupEmpty_BridgeShifts(t *testing.T) { @@ -142,10 +140,9 @@ func TestDueGroupEmpty_BridgeShifts(t *testing.T) { t.Error("Tags should be shed (not enough width)") } - // bridge at index 2 (DueGroup→DependsOn), 2 left gaps - // free=149-123=26, perGap=min(26/2,7)=7, gaps[0]=8,gaps[1]=8 - // right expand: remaining=149-90-8-8-1=42, DependsOn=30+12=42 - expectedGaps := []int{8, 8, 1} + // bridge at index 2 (DueGroup→DependsOn), bridge reserved at 3 + // left gaps expand to 8, right sections absorb remaining + expectedGaps := []int{8, 8, 3} for i, g := range plan.Gaps { if g != expectedGaps[i] { t.Errorf("gap[%d] = %d, want %d", i, g, expectedGaps[i]) @@ -248,8 +245,8 @@ func TestLeftSideGapsCapped(t *testing.T) { t.Fatalf("expected 6 sections, got %d", len(plan.Sections)) } - // left-side gaps capped at 8, bridge+right gaps at 1 - expectedGaps := []int{8, 8, 1, 1, 1} + // left-side gaps capped at 8, bridge reserved at minBridgeGap, right gaps at 1 + expectedGaps := []int{8, 8, 3, 1, 1} for i, g := range plan.Gaps { if g != expectedGaps[i] { t.Errorf("gap[%d] = %d, want %d", i, g, expectedGaps[i]) @@ -277,14 +274,14 @@ func TestLeftSideGapsCapped(t *testing.T) { func TestLeftSideGapsCapped_AllLeftSide(t *testing.T) { // 3 left-side sections only, wide terminal // widths = 90, free = 110, 2 gaps - // left gaps: perGap=min(110/2,7)=7, gaps=[8,8], used=106, leftover=94 → last gap + // left gaps: perGap=min(110/2,7)=7, gaps=[8,8] — left-aligned, leftover unallocated plan := CalculateMetadataLayout(200, allSixSections()[:3]) if len(plan.Sections) != 3 { t.Fatalf("expected 3 sections, got %d", len(plan.Sections)) } - expected := []int{8, 102} + expected := []int{8, 8} for i, g := range plan.Gaps { if g != expected[i] { t.Errorf("gap[%d] = %d, want %d", i, g, expected[i]) @@ -299,10 +296,10 @@ func TestTagsMinWidth(t *testing.T) { want int }{ {"longest tag wins", []string{"backend", "bug", "frontend", "search"}, 8}, - {"label wins over short tags", []string{"a", "b"}, 5}, + {"label wins over short tags", []string{"a", "b"}, 7}, {"single long tag", []string{"infrastructure"}, 14}, - {"empty tags", nil, 5}, - {"tag exactly label length", []string{"abcd"}, 5}, + {"empty tags", nil, 7}, + {"tag exactly label length", []string{"abcd"}, 7}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -393,3 +390,51 @@ func contains(ids []SectionID, target SectionID) bool { } return false } + +func TestBridgeGapMinimum(t *testing.T) { + // when right-side sections exist and space allows, bridge gap >= minBridgeGap + plan := CalculateMetadataLayout(170, allSixSections()) + + bridgeIdx := -1 + for i := 0; i < len(plan.Sections)-1; i++ { + if !isRightSide(plan.Sections[i].ID) && isRightSide(plan.Sections[i+1].ID) { + bridgeIdx = i + break + } + } + if bridgeIdx < 0 { + t.Fatal("expected a bridge gap between left-side and right-side sections") + } + if plan.Gaps[bridgeIdx] < minBridgeGap { + t.Errorf("bridge gap = %d, want >= %d", plan.Gaps[bridgeIdx], minBridgeGap) + } + verifyTotalWidth(t, plan, 170) +} + +func TestBridgeGapCapped(t *testing.T) { + // on a very wide terminal, bridge gap should not exceed maxBridgeGap + // use sections where right-side sections are absent except tags (small width) + // so the bridge gap accumulates most of the overflow + sections := []SectionInput{ + {ID: SectionStatusGroup, Width: 30, HasContent: true}, + {ID: SectionPeopleGroup, Width: 30, HasContent: true}, + {ID: SectionDueGroup, Width: 30, HasContent: true}, + {ID: SectionTags, Width: 7, HasContent: true}, + } + plan := CalculateMetadataLayout(500, sections) + + bridgeIdx := -1 + for i := 0; i < len(plan.Sections)-1; i++ { + if !isRightSide(plan.Sections[i].ID) && isRightSide(plan.Sections[i+1].ID) { + bridgeIdx = i + break + } + } + if bridgeIdx < 0 { + t.Fatal("expected a bridge gap") + } + if plan.Gaps[bridgeIdx] > maxBridgeGap { + t.Errorf("bridge gap = %d, want <= %d", plan.Gaps[bridgeIdx], maxBridgeGap) + } + verifyTotalWidth(t, plan, 500) +} diff --git a/view/taskdetail/render_helpers.go b/view/taskdetail/render_helpers.go index 45d9d4a..76ace37 100644 --- a/view/taskdetail/render_helpers.go +++ b/view/taskdetail/render_helpers.go @@ -163,6 +163,7 @@ func RenderTagsColumn(task *taskpkg.Task) tview.Primitive { label.SetBorderPadding(0, 0, 0, 0) col := tview.NewFlex().SetDirection(tview.FlexRow) + col.SetBorderPadding(0, 0, 1, 1) col.AddItem(label, 1, 0, false) col.AddItem(component.NewWordList(task.Tags), 0, 1, false) return col @@ -300,7 +301,7 @@ func (r *responsiveMetadataRow) rebuild(width int) { // is larger), with a floor of 5 to avoid degenerate layouts. func tagsMinWidth(tags []string) int { const label = "Tags" - const floor = 5 + const floor = 7 longest := len(label) for _, tag := range tags { if len(tag) > longest { diff --git a/view/taskdetail/task_edit_fields.go b/view/taskdetail/task_edit_fields.go index 21f807b..0462a62 100644 --- a/view/taskdetail/task_edit_fields.go +++ b/view/taskdetail/task_edit_fields.go @@ -119,6 +119,26 @@ func (ev *TaskEditView) ensureDueInput(task *taskpkg.Task) *component.DateEdit { return ev.dueInput } +func (ev *TaskEditView) ensureRecurrenceInput(task *taskpkg.Task) *component.RecurrenceEdit { + if ev.recurrenceInput == nil { + colors := config.GetColors() + ev.recurrenceInput = component.NewRecurrenceEdit() + ev.recurrenceInput.SetLabel(getFocusMarker(colors) + "Recurrence: ") + + ev.recurrenceInput.SetChangeHandler(func(value string) { + ev.updateValidationState() + + if ev.onRecurrenceSave != nil { + ev.onRecurrenceSave(value) + } + }) + + ev.recurrenceInput.SetInitialValue(string(task.Recurrence)) + } + + return ev.recurrenceInput +} + func (ev *TaskEditView) ensureAssigneeSelectList(task *taskpkg.Task) *component.EditSelectList { if ev.assigneeSelectList == nil { var assigneeOptions []string diff --git a/view/taskdetail/task_edit_nav.go b/view/taskdetail/task_edit_nav.go index cdc56e7..573535a 100644 --- a/view/taskdetail/task_edit_nav.go +++ b/view/taskdetail/task_edit_nav.go @@ -58,6 +58,10 @@ func (ev *TaskEditView) SetFocusedField(field model.EditField) { if ev.dueInput != nil { ev.focusSetter(ev.dueInput) } + case model.EditFieldRecurrence: + if ev.recurrenceInput != nil { + ev.focusSetter(ev.recurrenceInput) + } case model.EditFieldTitle: if ev.titleInput != nil { ev.focusSetter(ev.titleInput) @@ -94,6 +98,9 @@ func (ev *TaskEditView) IsEditFieldFocused() bool { if ev.dueInput != nil && ev.dueInput.HasFocus() { return true } + if ev.recurrenceInput != nil && ev.recurrenceInput.HasFocus() { + return true + } return false } @@ -150,6 +157,11 @@ func (ev *TaskEditView) CycleFieldValueUp() bool { ev.dueInput.InputHandler()(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone), nil) return true } + case model.EditFieldRecurrence: + if ev.recurrenceInput != nil { + ev.recurrenceInput.CyclePrev() + return true + } } return false } @@ -187,10 +199,35 @@ func (ev *TaskEditView) CycleFieldValueDown() bool { ev.dueInput.InputHandler()(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone), nil) return true } + case model.EditFieldRecurrence: + if ev.recurrenceInput != nil { + ev.recurrenceInput.CycleNext() + return true + } } return false } +// MoveRecurrencePartLeft moves the recurrence editor to the frequency part. +// Returns true if the recurrence field is focused and the navigation was handled. +func (ev *TaskEditView) MoveRecurrencePartLeft() bool { + if ev.focusedField != model.EditFieldRecurrence || ev.recurrenceInput == nil { + return false + } + ev.recurrenceInput.MovePartLeft() + return true +} + +// MoveRecurrencePartRight moves the recurrence editor to the value part. +// Returns true if the recurrence field is focused and the navigation was handled. +func (ev *TaskEditView) MoveRecurrencePartRight() bool { + if ev.focusedField != model.EditFieldRecurrence || ev.recurrenceInput == nil { + return false + } + ev.recurrenceInput.MovePartRight() + return true +} + // UpdateHeaderForField updates the registry with field-specific actions func (ev *TaskEditView) UpdateHeaderForField(field model.EditField) { if ev.descOnly { diff --git a/view/taskdetail/task_edit_view.go b/view/taskdetail/task_edit_view.go index d367899..ed47d33 100644 --- a/view/taskdetail/task_edit_view.go +++ b/view/taskdetail/task_edit_view.go @@ -37,19 +37,21 @@ type TaskEditView struct { assigneeSelectList *component.EditSelectList pointsInput *component.IntEditSelect dueInput *component.DateEdit + recurrenceInput *component.RecurrenceEdit // All callbacks - onTitleSave func(string) - onTitleChange func(string) - onTitleCancel func() - onDescSave func(string) - onDescCancel func() - onStatusSave func(string) - onTypeSave func(string) - onPrioritySave func(int) - onAssigneeSave func(string) - onPointsSave func(int) - onDueSave func(string) + onTitleSave func(string) + onTitleChange func(string) + onTitleCancel func() + onDescSave func(string) + onDescCancel func() + onStatusSave func(string) + onTypeSave func(string) + onPrioritySave func(int) + onAssigneeSave func(string) + onPointsSave func(int) + onDueSave func(string) + onRecurrenceSave func(string) } // Compile-time interface checks @@ -86,6 +88,7 @@ func NewTaskEditView(taskStore store.Store, taskID string, imageManager *navtvie ev.ensureAssigneeSelectList(task) ev.ensurePointsInput(task) ev.ensureDueInput(task) + ev.ensureRecurrenceInput(task) } ev.refresh() @@ -220,7 +223,7 @@ func (ev *TaskEditView) buildMetadataColumns(task *taskpkg.Task, ctx FieldRender // Column 3: Due, Recurrence col3 := tview.NewFlex().SetDirection(tview.FlexRow) col3.AddItem(ev.buildDueField(task, ctx), 1, 0, false) - col3.AddItem(RenderRecurrenceText(task, colors), 1, 0, false) + col3.AddItem(ev.buildRecurrenceField(task, ctx), 1, 0, false) return col1, col2, col3 } @@ -267,6 +270,13 @@ func (ev *TaskEditView) buildDueField(task *taskpkg.Task, ctx FieldRenderContext return RenderDueText(task, ctx.Colors) } +func (ev *TaskEditView) buildRecurrenceField(task *taskpkg.Task, ctx FieldRenderContext) tview.Primitive { + if ctx.FocusedField == model.EditFieldRecurrence { + return ev.ensureRecurrenceInput(task) + } + return RenderRecurrenceText(task, ctx.Colors) +} + func (ev *TaskEditView) buildDescription(task *taskpkg.Task) tview.Primitive { textArea := ev.ensureDescTextArea(task) return textArea @@ -396,6 +406,9 @@ func (ev *TaskEditView) buildTaskFromWidgets() *taskpkg.Task { snapshot.Due = parsed } } + if ev.recurrenceInput != nil { + snapshot.Recurrence = taskpkg.Recurrence(ev.recurrenceInput.GetValue()) + } return snapshot } @@ -560,3 +573,8 @@ func (ev *TaskEditView) SetPointsSaveHandler(handler func(int)) { func (ev *TaskEditView) SetDueSaveHandler(handler func(string)) { ev.onDueSave = handler } + +// SetRecurrenceSaveHandler sets the callback for when recurrence is saved +func (ev *TaskEditView) SetRecurrenceSaveHandler(handler func(string)) { + ev.onRecurrenceSave = handler +}