edit recurrence

This commit is contained in:
booleanmaybe 2026-03-20 17:34:55 -04:00
parent 9554927530
commit 9f26d7ad11
16 changed files with 1057 additions and 51 deletions

View file

@ -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())
}
}

View file

@ -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)
}
}

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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"},
}

View file

@ -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
}

View file

@ -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

View file

@ -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}

View file

@ -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)
}

View file

@ -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 {

View file

@ -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

View file

@ -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 {

View file

@ -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
}