diff --git a/internal/ui/datepicker_test.go b/internal/ui/datepicker_test.go index f5c1b82..7274990 100644 --- a/internal/ui/datepicker_test.go +++ b/internal/ui/datepicker_test.go @@ -6,6 +6,8 @@ import ( tea "github.com/charmbracelet/bubbletea" datepicker "github.com/ethanefung/bubble-datepicker" + "github.com/rwejlgaard/org/internal/config" + "github.com/rwejlgaard/org/internal/model" ) func sameDay(a, b time.Time) bool { @@ -41,3 +43,119 @@ func TestNewDatePickerUsesExisting(t *testing.T) { t.Fatalf("expected %v, got %v", existing, dp.Time) } } + +func newDateModeModel(dateType string, existing *time.Time) (uiModel, *model.Item) { + cfg := config.DefaultConfig() + item := &model.Item{Title: "Task", Level: 1, Deadline: existing, Scheduled: existing} + of := &model.OrgFile{Items: []*model.Item{item}} + m := InitialModel(of, cfg, false, "") + m.editingItem = item + m.dateTextFocused = false + m.datepicker = newDatePicker(existing) + if dateType == "DEADLINE" { + m.mode = modeSetDeadline + } else { + m.mode = modeSetScheduled + } + return m, item +} + +func TestCalendarEnterAppliesDate(t *testing.T) { + m, item := newDateModeModel("DEADLINE", nil) + m.datepicker.SetTime(time.Date(2027, time.March, 9, 0, 0, 0, 0, time.Local)) + res, _ := m.updateSetDate(tea.KeyMsg{Type: tea.KeyEnter}, "DEADLINE") + rm := res.(uiModel) + if item.Deadline == nil || !sameDay(*item.Deadline, time.Date(2027, time.March, 9, 0, 0, 0, 0, time.Local)) { + t.Fatalf("expected deadline 2027-03-09, got %v", item.Deadline) + } + if rm.mode != modeList { + t.Fatalf("expected mode to return to list, got %v", rm.mode) + } +} + +func TestCalendarXClearsDate(t *testing.T) { + existing := time.Date(2027, time.March, 9, 0, 0, 0, 0, time.Local) + m, item := newDateModeModel("SCHEDULED", &existing) + res, _ := m.updateSetDate(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}, "SCHEDULED") + rm := res.(uiModel) + if item.Scheduled != nil { + t.Fatalf("expected scheduled cleared, got %v", item.Scheduled) + } + if rm.mode != modeList { + t.Fatalf("expected mode to return to list, got %v", rm.mode) + } +} + +func TestDigitSwitchesToTextFocus(t *testing.T) { + m, _ := newDateModeModel("DEADLINE", nil) + res, _ := m.updateSetDate(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}, "DEADLINE") + rm := res.(uiModel) + if !rm.dateTextFocused { + t.Fatal("expected text focus after typing a digit") + } + if rm.textinput.Value() != "2" { + t.Fatalf("expected text seeded with '2', got %q", rm.textinput.Value()) + } + if rm.mode != modeSetDeadline { + t.Fatalf("expected to stay in deadline mode, got %v", rm.mode) + } +} + +func TestTextEnterParsesRelativeDate(t *testing.T) { + m, item := newDateModeModel("DEADLINE", nil) + m.dateTextFocused = true + m.textinput.SetValue("+7") + res, _ := m.updateSetDate(tea.KeyMsg{Type: tea.KeyEnter}, "DEADLINE") + rm := res.(uiModel) + want := time.Now().AddDate(0, 0, 7) + if item.Deadline == nil || !sameDay(*item.Deadline, want) { + t.Fatalf("expected deadline %v, got %v", want, item.Deadline) + } + if rm.mode != modeList { + t.Fatalf("expected mode to return to list, got %v", rm.mode) + } +} + +func TestTextEnterInvalidStaysInTextFocus(t *testing.T) { + m, item := newDateModeModel("DEADLINE", nil) + m.dateTextFocused = true + m.textinput.SetValue("not-a-date") + res, _ := m.updateSetDate(tea.KeyMsg{Type: tea.KeyEnter}, "DEADLINE") + rm := res.(uiModel) + if item.Deadline != nil { + t.Fatalf("expected no deadline set, got %v", item.Deadline) + } + if rm.mode != modeSetDeadline || !rm.dateTextFocused { + t.Fatalf("expected to stay in text focus, got mode=%v textFocused=%v", rm.mode, rm.dateTextFocused) + } +} + +func TestTextEscReturnsToCalendar(t *testing.T) { + m, _ := newDateModeModel("DEADLINE", nil) + m.dateTextFocused = true + m.textinput.SetValue("+3") + res, _ := m.updateSetDate(tea.KeyMsg{Type: tea.KeyEsc}, "DEADLINE") + rm := res.(uiModel) + if rm.dateTextFocused { + t.Fatal("expected to return to calendar focus") + } + if rm.mode != modeSetDeadline { + t.Fatalf("expected to stay in deadline mode, got %v", rm.mode) + } + if rm.textinput.Value() != "" { + t.Fatalf("expected text cleared, got %q", rm.textinput.Value()) + } +} + +func TestCalendarEscCancels(t *testing.T) { + existing := time.Date(2027, time.March, 9, 0, 0, 0, 0, time.Local) + m, item := newDateModeModel("DEADLINE", &existing) + res, _ := m.updateSetDate(tea.KeyMsg{Type: tea.KeyEsc}, "DEADLINE") + rm := res.(uiModel) + if item.Deadline == nil { + t.Fatal("expected existing deadline untouched on cancel") + } + if rm.mode != modeList { + t.Fatalf("expected mode to return to list, got %v", rm.mode) + } +} diff --git a/internal/ui/modes.go b/internal/ui/modes.go index 5f7facd..5007bf6 100644 --- a/internal/ui/modes.go +++ b/internal/ui/modes.go @@ -626,102 +626,150 @@ func (m uiModel) updateSetScheduled(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateSetDate(msg, "SCHEDULED") } -func (m uiModel) updateSetDate(msg tea.Msg, dateType string) (tea.Model, tea.Cmd) { - var cmd tea.Cmd +// applyDate sets the deadline/scheduled date on the editing item and syncs its +// property line in Notes. +func (m *uiModel) applyDate(dateType string, dateVal time.Time) { + prefixDate := "SCHEDULED:" + setDateMsg := "Scheduled date set!" + if dateType == "DEADLINE" { + prefixDate = "DEADLINE:" + setDateMsg = "Deadline set!" + m.editingItem.Deadline = &dateVal + } else { + m.editingItem.Scheduled = &dateVal + } + updatedNotes := false + for i, note := range m.editingItem.Notes { + if strings.HasPrefix(strings.TrimSpace(note), prefixDate) { + m.editingItem.Notes[i] = fmt.Sprintf("%s <%s>", prefixDate, parser.FormatOrgDate(dateVal)) + updatedNotes = true + break + } + } + if !updatedNotes { + var filtered []string + for _, note := range m.editingItem.Notes { + if !strings.HasPrefix(strings.TrimSpace(note), prefixDate) { + filtered = append(filtered, note) + } + } + m.editingItem.Notes = filtered + } + m.setStatus(setDateMsg) +} + +// clearDate removes the deadline/scheduled date and its property line. +func (m *uiModel) clearDate(dateType string) { + prefixDate := "SCHEDULED:" + clearedMsg := "Scheduled date cleared!" + if dateType == "DEADLINE" { + prefixDate = "DEADLINE:" + clearedMsg = "Deadline cleared!" + m.editingItem.Deadline = nil + } else { + m.editingItem.Scheduled = nil + } + + var filtered []string + for _, note := range m.editingItem.Notes { + if !strings.HasPrefix(strings.TrimSpace(note), prefixDate) { + filtered = append(filtered, note) + } + } + m.editingItem.Notes = filtered + m.setStatus(clearedMsg) +} + +func (m uiModel) updateSetDate(msg tea.Msg, dateType string) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.textinput.Width = 50 - + return m, nil case tea.KeyMsg: - switch msg.Type { - case tea.KeyEnter: - input := strings.TrimSpace(m.textinput.Value()) - if m.editingItem != nil { - var prefixDate string - var clearedDateMsg string - var setDateMsg string + if m.dateTextFocused { + return m.updateSetDateText(msg, dateType) + } + return m.updateSetDateCalendar(msg, dateType) + } + // Forward other messages (e.g. cursor blink) to the text input when focused. + if m.dateTextFocused { + var cmd tea.Cmd + m.textinput, cmd = m.textinput.Update(msg) + return m, cmd + } + return m, nil +} - if dateType == "DEADLINE" { - prefixDate = "DEADLINE:" - clearedDateMsg = "Deadline cleared!" - setDateMsg = "Deadline set!" - } else { - prefixDate = "SCHEDULED:" - clearedDateMsg = "Scheduled date cleared!" - setDateMsg = "Scheduled date set!" - } - - if input == "" { - // Empty input clears the date - if dateType == "DEADLINE" { - m.editingItem.Deadline = nil - } else { - m.editingItem.Scheduled = nil - } - - // Remove property line from notes - var filteredNotes []string - for _, note := range m.editingItem.Notes { - trimmedNote := strings.TrimSpace(note) - if !strings.HasPrefix(trimmedNote, prefixDate) { - filteredNotes = append(filteredNotes, note) - } - } - m.editingItem.Notes = filteredNotes - m.setStatus(clearedDateMsg) - } else { - dateVal, err := parseDateInput(input) - if err != nil { - m.setStatus(fmt.Sprintf("Invalid date: %v", err)) - } else { - if dateType == "DEADLINE" { - m.editingItem.Deadline = &dateVal - } else { - m.editingItem.Scheduled = &dateVal - } - - // Also update or add property line in notes - updatedNotes := false - for i, note := range m.editingItem.Notes { - trimmedNote := strings.TrimSpace(note) - if strings.HasPrefix(trimmedNote, prefixDate) { - m.editingItem.Notes[i] = fmt.Sprintf("%s <%s>", prefixDate, parser.FormatOrgDate(dateVal)) - updatedNotes = true - break - } - } - // If property wasn't in notes, it will be added by writeItem - if !updatedNotes { - // Remove old property lines just to be safe - var filteredNotes []string - for _, note := range m.editingItem.Notes { - trimmedNote := strings.TrimSpace(note) - if !strings.HasPrefix(trimmedNote, prefixDate) { - filteredNotes = append(filteredNotes, note) - } - } - m.editingItem.Notes = filteredNotes - } - m.setStatus(setDateMsg) - } +// closeDateMode returns to the list view and clears set-date state. +func (m uiModel) closeDateMode() (tea.Model, tea.Cmd) { + m.mode = modeList + m.textinput.Blur() + m.textinput.SetValue("") + m.dateTextFocused = false + m.editingItem = nil + return m, nil +} + +func (m uiModel) updateSetDateCalendar(msg tea.KeyMsg, dateType string) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + if m.editingItem != nil { + m.applyDate(dateType, m.datepicker.Time) + } + return m.closeDateMode() + case tea.KeyEsc: + m.setStatus("Cancelled") + return m.closeDateMode() + case tea.KeyRunes: + if len(msg.Runes) == 1 { + r := msg.Runes[0] + if r == 'x' { + if m.editingItem != nil { + m.clearDate(dateType) } + return m.closeDateMode() + } + if (r >= '0' && r <= '9') || r == '+' { + m.dateTextFocused = true + m.textinput.SetValue(string(r)) + m.textinput.CursorEnd() + m.textinput.Focus() + return m, textinput.Blink } - m.mode = modeList - m.textinput.Blur() - m.editingItem = nil - return m, nil - case tea.KeyEsc: - m.mode = modeList - m.textinput.Blur() - m.editingItem = nil - m.setStatus("Cancelled") - return m, nil } } + m.datepicker, _ = m.datepicker.Update(msg) + return m, nil +} +func (m uiModel) updateSetDateText(msg tea.KeyMsg, dateType string) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + input := strings.TrimSpace(m.textinput.Value()) + if m.editingItem == nil { + return m.closeDateMode() + } + if input == "" { + m.clearDate(dateType) + return m.closeDateMode() + } + dateVal, err := parseDateInput(input) + if err != nil { + m.setStatus(fmt.Sprintf("Invalid date: %v", err)) + return m, nil // stay in text focus so the user can fix it + } + m.applyDate(dateType, dateVal) + return m.closeDateMode() + case tea.KeyEsc: + m.dateTextFocused = false + m.textinput.Blur() + m.textinput.SetValue("") + return m, nil + } + var cmd tea.Cmd m.textinput, cmd = m.textinput.Update(msg) return m, cmd }