mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
edit recurrence
This commit is contained in:
parent
9554927530
commit
9f26d7ad11
16 changed files with 1057 additions and 51 deletions
247
component/recurrence_edit.go
Normal file
247
component/recurrence_edit.go
Normal 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())
|
||||
}
|
||||
}
|
||||
192
component/recurrence_edit_test.go
Normal file
192
component/recurrence_edit_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue