mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
recurrence auto-computes due date
This commit is contained in:
parent
973ea911a6
commit
8a9477d3fb
8 changed files with 250 additions and 17 deletions
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue