From 8a9477d3fb5768956e41d5df565cbf44396203b6 Mon Sep 17 00:00:00 2001 From: booleanmaybe Date: Tue, 24 Mar 2026 22:02:31 -0400 Subject: [PATCH] recurrence auto-computes due date --- controller/task_detail.go | 7 +++ model/edit_field.go | 39 ++++++++++------ model/edit_field_test.go | 50 ++++++++++++++++++++ task/recurrence.go | 72 +++++++++++++++++++++++++++++ task/recurrence_test.go | 48 +++++++++++++++++++ view/taskdetail/task_edit_fields.go | 10 +++- view/taskdetail/task_edit_nav.go | 15 +++++- view/taskdetail/task_edit_view.go | 26 +++++++++++ 8 files changed, 250 insertions(+), 17 deletions(-) diff --git a/controller/task_detail.go b/controller/task_detail.go index 1bd33a9..bd7cb25 100644 --- a/controller/task_detail.go +++ b/controller/task_detail.go @@ -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) + } }) } diff --git a/model/edit_field.go b/model/edit_field.go index c2be576..9df02bb 100644 --- a/model/edit_field.go +++ b/model/edit_field.go @@ -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 } diff --git a/model/edit_field_test.go b/model/edit_field_test.go index b83b606..bcfa07e 100644 --- a/model/edit_field_test.go +++ b/model/edit_field_test.go @@ -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 diff --git a/task/recurrence.go b/task/recurrence.go index 32248e7..b335c91 100644 --- a/task/recurrence.go +++ b/task/recurrence.go @@ -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 diff --git a/task/recurrence_test.go b/task/recurrence_test.go index d8b46a4..82458b2 100644 --- a/task/recurrence_test.go +++ b/task/recurrence_test.go @@ -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")) + } + }) + } +} diff --git a/view/taskdetail/task_edit_fields.go b/view/taskdetail/task_edit_fields.go index 0462a62..fa893da 100644 --- a/view/taskdetail/task_edit_fields.go +++ b/view/taskdetail/task_edit_fields.go @@ -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)) diff --git a/view/taskdetail/task_edit_nav.go b/view/taskdetail/task_edit_nav.go index 5ca46de..0e52af5 100644 --- a/view/taskdetail/task_edit_nav.go +++ b/view/taskdetail/task_edit_nav.go @@ -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 diff --git a/view/taskdetail/task_edit_view.go b/view/taskdetail/task_edit_view.go index cded940..67b6333 100644 --- a/view/taskdetail/task_edit_view.go +++ b/view/taskdetail/task_edit_view.go @@ -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)