recurrence auto-computes due date

This commit is contained in:
booleanmaybe 2026-03-24 22:02:31 -04:00
parent 973ea911a6
commit 8a9477d3fb
8 changed files with 250 additions and 17 deletions

View file

@ -400,6 +400,8 @@ func (tc *TaskController) SaveDue(dateStr string) bool {
}
// SaveRecurrence saves the new recurrence cron expression to the current task.
// When recurrence is set, Due is auto-computed as the next occurrence.
// When recurrence is cleared, Due is also cleared.
// Returns true if the recurrence was successfully updated, false otherwise.
func (tc *TaskController) SaveRecurrence(cron string) bool {
r := taskpkg.Recurrence(cron)
@ -409,6 +411,11 @@ func (tc *TaskController) SaveRecurrence(cron string) bool {
}
return tc.updateTaskField(func(t *taskpkg.Task) {
t.Recurrence = r
if r == taskpkg.RecurrenceNone {
t.Due = time.Time{}
} else {
t.Due = taskpkg.NextOccurrence(r)
}
})
}

View file

@ -28,33 +28,46 @@ var fieldOrder = []EditField{
EditFieldDescription,
}
// NextField returns the next field in the edit cycle (stops at last field, no wrapping)
// noSkip is a predicate that skips nothing, used by NextField/PrevField.
var noSkip = func(EditField) bool { return false }
// NextField returns the next field in the edit cycle (stops at last field, no wrapping).
func NextField(current EditField) EditField {
return NextFieldSkipping(current, noSkip)
}
// PrevField returns the previous field in the edit cycle (stops at first field, no wrapping).
func PrevField(current EditField) EditField {
return PrevFieldSkipping(current, noSkip)
}
// NextFieldSkipping returns the next field, skipping fields where skip returns true.
func NextFieldSkipping(current EditField, skip func(EditField) bool) EditField {
for i, field := range fieldOrder {
if field == current {
// stop at last field instead of wrapping
if i == len(fieldOrder)-1 {
return current
for j := i + 1; j < len(fieldOrder); j++ {
if !skip(fieldOrder[j]) {
return fieldOrder[j]
}
}
return fieldOrder[i+1]
return current
}
}
// default to title if current field not found
return EditFieldTitle
}
// PrevField returns the previous field in the edit cycle (stops at first field, no wrapping)
func PrevField(current EditField) EditField {
// PrevFieldSkipping returns the previous field, skipping fields where skip returns true.
func PrevFieldSkipping(current EditField, skip func(EditField) bool) EditField {
for i, field := range fieldOrder {
if field == current {
// stop at first field instead of wrapping
if i == 0 {
return current
for j := i - 1; j >= 0; j-- {
if !skip(fieldOrder[j]) {
return fieldOrder[j]
}
}
return fieldOrder[i-1]
return current
}
}
// default to title if current field not found
return EditFieldTitle
}

View file

@ -102,6 +102,56 @@ func TestFieldCycling(t *testing.T) {
}
}
func TestNextFieldSkipping(t *testing.T) {
skipDue := func(f EditField) bool { return f == EditFieldDue }
tests := []struct {
name string
current EditField
skip func(EditField) bool
expected EditField
}{
{"assignee skips due to recurrence", EditFieldAssignee, skipDue, EditFieldRecurrence},
{"due itself not relevant (would not be focused)", EditFieldDue, skipDue, EditFieldRecurrence},
{"recurrence to description (no skip)", EditFieldRecurrence, skipDue, EditFieldDescription},
{"description stays (end of list)", EditFieldDescription, skipDue, EditFieldDescription},
{"title to status (no skip involved)", EditFieldTitle, skipDue, EditFieldStatus},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := NextFieldSkipping(tt.current, tt.skip)
if got != tt.expected {
t.Errorf("NextFieldSkipping(%v) = %v, want %v", tt.current, got, tt.expected)
}
})
}
}
func TestPrevFieldSkipping(t *testing.T) {
skipDue := func(f EditField) bool { return f == EditFieldDue }
tests := []struct {
name string
current EditField
skip func(EditField) bool
expected EditField
}{
{"recurrence skips due to assignee", EditFieldRecurrence, skipDue, EditFieldAssignee},
{"assignee to points (no skip)", EditFieldAssignee, skipDue, EditFieldPoints},
{"title stays (start of list)", EditFieldTitle, skipDue, EditFieldTitle},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := PrevFieldSkipping(tt.current, tt.skip)
if got != tt.expected {
t.Errorf("PrevFieldSkipping(%v) = %v, want %v", tt.current, got, tt.expected)
}
})
}
}
func TestIsEditableField(t *testing.T) {
tests := []struct {
name string

View file

@ -6,6 +6,7 @@ import (
"regexp"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
)
@ -167,6 +168,77 @@ func WeeklyRecurrence(dayName string) Recurrence {
return Recurrence("0 0 * * " + abbrev)
}
// weekdayNameToGoWeekday maps full day names to time.Weekday for next-occurrence calculation.
var weekdayNameToGoWeekday = map[string]time.Weekday{
"Monday": time.Monday, "Tuesday": time.Tuesday, "Wednesday": time.Wednesday,
"Thursday": time.Thursday, "Friday": time.Friday, "Saturday": time.Saturday, "Sunday": time.Sunday,
}
// NextOccurrence computes the next occurrence date from today for a given recurrence.
// Returns zero time for RecurrenceNone.
func NextOccurrence(r Recurrence) time.Time {
return NextOccurrenceFrom(r, time.Now())
}
// NextOccurrenceFrom computes the next occurrence date relative to ref.
// Only the date part of ref is used; result is always midnight UTC.
// Only daily, weekly, and monthly recurrences are supported.
// Unrecognized patterns return zero time, same as RecurrenceNone.
func NextOccurrenceFrom(r Recurrence, ref time.Time) time.Time {
if r == RecurrenceNone {
return time.Time{}
}
today := time.Date(ref.Year(), ref.Month(), ref.Day(), 0, 0, 0, 0, time.UTC)
if r == RecurrenceDaily {
return today.AddDate(0, 0, 1)
}
if dayName, ok := WeekdayFromRecurrence(r); ok {
target := weekdayNameToGoWeekday[dayName]
daysUntil := (int(target) - int(today.Weekday()) + 7) % 7
if daysUntil == 0 {
daysUntil = 7 // same day → next week
}
return today.AddDate(0, 0, daysUntil)
}
if day, ok := IsMonthlyRecurrence(r); ok {
return nextMonthlyOccurrence(today, day)
}
return time.Time{}
}
// nextMonthlyOccurrence returns the next date with the given day-of-month,
// capping to the last day of the target month if needed.
func nextMonthlyOccurrence(today time.Time, day int) time.Time {
if today.Day() < day {
return clampDayInMonth(today.Year(), today.Month(), day)
}
// target day already passed (or is today) → next month
// use first of current month + 1 month to avoid Go's date normalization issues
// (e.g., March 31 + 1 month = May 1, not April 30)
nextMonth := time.Date(today.Year(), today.Month()+1, 1, 0, 0, 0, 0, time.UTC)
return clampDayInMonth(nextMonth.Year(), nextMonth.Month(), day)
}
// clampDayInMonth returns midnight UTC on the given year/month/day,
// capping the day to the last day of the month.
func clampDayInMonth(year int, month time.Month, day int) time.Time {
lastDay := daysInMonth(year, month)
if day > lastDay {
day = lastDay
}
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
}
// daysInMonth returns the number of days in the given month.
func daysInMonth(year int, month time.Month) int {
return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
}
// built at init from knownRecurrences
var (
cronToDisplay map[Recurrence]string

View file

@ -2,6 +2,7 @@ package task
import (
"testing"
"time"
"gopkg.in/yaml.v3"
)
@ -572,3 +573,50 @@ func TestRecurrenceValue_IsZero(t *testing.T) {
})
}
}
func TestNextOccurrenceFrom(t *testing.T) {
d := func(year int, month time.Month, day int) time.Time {
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
}
tests := []struct {
name string
r Recurrence
ref time.Time
want time.Time
}{
{"none returns zero", RecurrenceNone, d(2026, 3, 24), time.Time{}},
{"daily returns tomorrow", RecurrenceDaily, d(2026, 3, 24), d(2026, 3, 25)},
// weekly: today is that day → next week
{"weekly monday on monday", WeeklyRecurrence("Monday"), d(2026, 3, 23), d(2026, 3, 30)},
// weekly: today is wednesday, next monday is 5 days out
{"weekly monday on wednesday", WeeklyRecurrence("Monday"), d(2026, 3, 25), d(2026, 3, 30)},
// weekly: today is thursday, next friday is 1 day out
{"weekly friday on thursday", WeeklyRecurrence("Friday"), d(2026, 3, 26), d(2026, 3, 27)},
// weekly: today is sunday, next monday is tomorrow
{"weekly monday on sunday", WeeklyRecurrence("Monday"), d(2026, 3, 29), d(2026, 3, 30)},
// monthly: before target day → this month
{"monthly 15th on 10th", MonthlyRecurrence(15), d(2026, 3, 10), d(2026, 3, 15)},
// monthly: on target day → next month
{"monthly 15th on 15th", MonthlyRecurrence(15), d(2026, 3, 15), d(2026, 4, 15)},
// monthly: past target day → next month
{"monthly 15th on 20th", MonthlyRecurrence(15), d(2026, 3, 20), d(2026, 4, 15)},
// monthly: day 31 in february → capped to feb 28
{"monthly 31st in feb", MonthlyRecurrence(31), d(2026, 2, 1), d(2026, 2, 28)},
// monthly: day 31 on 31st → next month (april has 30 days, capped)
{"monthly 31st on mar 31", MonthlyRecurrence(31), d(2026, 3, 31), d(2026, 4, 30)},
// monthly: day 1 at year boundary
{"monthly 1st on dec 5", MonthlyRecurrence(1), d(2026, 12, 5), d(2027, 1, 1)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := NextOccurrenceFrom(tt.r, tt.ref)
if !got.Equal(tt.want) {
t.Errorf("NextOccurrenceFrom(%q, %v) = %v, want %v", tt.r, tt.ref.Format("2006-01-02"), got.Format("2006-01-02"), tt.want.Format("2006-01-02"))
}
})
}
}

View file

@ -126,11 +126,17 @@ func (ev *TaskEditView) ensureRecurrenceInput(task *taskpkg.Task) *component.Rec
ev.recurrenceInput.SetLabel(getFocusMarker(colors) + "Recurrence: ")
ev.recurrenceInput.SetChangeHandler(func(value string) {
ev.updateValidationState()
if ev.onRecurrenceSave != nil {
ev.onRecurrenceSave(value)
}
// sync due widget with auto-computed value from the updated in-memory task
ev.syncDueFromTask()
// full refresh needed: tview can't swap a single primitive in a flex layout,
// so we rebuild to toggle Due between input and read-only text
ev.refresh()
ev.updateValidationState()
})
ev.recurrenceInput.SetInitialValue(string(task.Recurrence))

View file

@ -109,7 +109,7 @@ func (ev *TaskEditView) FocusNextField() bool {
if ev.descOnly || ev.tagsOnly {
return false
}
nextField := model.NextField(ev.focusedField)
nextField := model.NextFieldSkipping(ev.focusedField, ev.shouldSkipField)
ev.SetFocusedField(nextField)
return true
}
@ -119,11 +119,16 @@ func (ev *TaskEditView) FocusPrevField() bool {
if ev.descOnly || ev.tagsOnly {
return false
}
prevField := model.PrevField(ev.focusedField)
prevField := model.PrevFieldSkipping(ev.focusedField, ev.shouldSkipField)
ev.SetFocusedField(prevField)
return true
}
// shouldSkipField returns true for fields that should be skipped during navigation.
func (ev *TaskEditView) shouldSkipField(field model.EditField) bool {
return field == model.EditFieldDue && ev.isDueReadOnly()
}
// CycleFieldValueUp cycles the currently focused field's value upward (previous)
func (ev *TaskEditView) CycleFieldValueUp() bool {
switch ev.focusedField {
@ -153,6 +158,9 @@ func (ev *TaskEditView) CycleFieldValueUp() bool {
return true
}
case model.EditFieldDue:
if ev.isDueReadOnly() {
return false
}
if ev.dueInput != nil {
ev.dueInput.InputHandler()(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone), nil)
return true
@ -195,6 +203,9 @@ func (ev *TaskEditView) CycleFieldValueDown() bool {
return true
}
case model.EditFieldDue:
if ev.isDueReadOnly() {
return false
}
if ev.dueInput != nil {
ev.dueInput.InputHandler()(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone), nil)
return true

View file

@ -296,12 +296,38 @@ func (ev *TaskEditView) buildPointsField(task *taskpkg.Task, ctx FieldRenderCont
}
func (ev *TaskEditView) buildDueField(task *taskpkg.Task, ctx FieldRenderContext) tview.Primitive {
if ev.isDueReadOnly() {
return RenderDueText(task, ctx.Colors)
}
if ctx.FocusedField == model.EditFieldDue {
return ev.ensureDueInput(task)
}
return RenderDueText(task, ctx.Colors)
}
// isDueReadOnly returns true when recurrence is set, making Due auto-computed.
func (ev *TaskEditView) isDueReadOnly() bool {
task := ev.GetTask()
return task != nil && task.Recurrence != taskpkg.RecurrenceNone
}
// syncDueFromTask updates the dueInput widget to reflect the auto-computed Due
// from the in-memory task. Called after recurrence changes.
func (ev *TaskEditView) syncDueFromTask() {
if ev.dueInput == nil {
return
}
task := ev.GetTask()
if task == nil {
return
}
var dateStr string
if !task.Due.IsZero() {
dateStr = task.Due.Format(taskpkg.DateFormat)
}
ev.dueInput.SetInitialValue(dateStr)
}
func (ev *TaskEditView) buildRecurrenceField(task *taskpkg.Task, ctx FieldRenderContext) tview.Primitive {
if ctx.FocusedField == model.EditFieldRecurrence {
return ev.ensureRecurrenceInput(task)