mirror of
https://github.com/RWejlgaard/org.git
synced 2026-09-02 01:15:35 +00:00
feat: calendar-first set-date modes with text fallback
Claude-Session: https://claude.ai/code/session_01BA1doywpQD49rP1vezTe9U
This commit is contained in:
parent
04676f1bab
commit
ecd261e377
2 changed files with 249 additions and 83 deletions
|
|
@ -6,6 +6,8 @@ import (
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
datepicker "github.com/ethanefung/bubble-datepicker"
|
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 {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -626,102 +626,150 @@ func (m uiModel) updateSetScheduled(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return m.updateSetDate(msg, "SCHEDULED")
|
return m.updateSetDate(msg, "SCHEDULED")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m uiModel) updateSetDate(msg tea.Msg, dateType string) (tea.Model, tea.Cmd) {
|
// applyDate sets the deadline/scheduled date on the editing item and syncs its
|
||||||
var cmd tea.Cmd
|
// 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) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
m.textinput.Width = 50
|
m.textinput.Width = 50
|
||||||
|
return m, nil
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.Type {
|
if m.dateTextFocused {
|
||||||
case tea.KeyEnter:
|
return m.updateSetDateText(msg, dateType)
|
||||||
input := strings.TrimSpace(m.textinput.Value())
|
}
|
||||||
if m.editingItem != nil {
|
return m.updateSetDateCalendar(msg, dateType)
|
||||||
var prefixDate string
|
}
|
||||||
var clearedDateMsg string
|
// Forward other messages (e.g. cursor blink) to the text input when focused.
|
||||||
var setDateMsg string
|
if m.dateTextFocused {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.textinput, cmd = m.textinput.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
if dateType == "DEADLINE" {
|
// closeDateMode returns to the list view and clears set-date state.
|
||||||
prefixDate = "DEADLINE:"
|
func (m uiModel) closeDateMode() (tea.Model, tea.Cmd) {
|
||||||
clearedDateMsg = "Deadline cleared!"
|
m.mode = modeList
|
||||||
setDateMsg = "Deadline set!"
|
m.textinput.Blur()
|
||||||
} else {
|
m.textinput.SetValue("")
|
||||||
prefixDate = "SCHEDULED:"
|
m.dateTextFocused = false
|
||||||
clearedDateMsg = "Scheduled date cleared!"
|
m.editingItem = nil
|
||||||
setDateMsg = "Scheduled date set!"
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if input == "" {
|
func (m uiModel) updateSetDateCalendar(msg tea.KeyMsg, dateType string) (tea.Model, tea.Cmd) {
|
||||||
// Empty input clears the date
|
switch msg.Type {
|
||||||
if dateType == "DEADLINE" {
|
case tea.KeyEnter:
|
||||||
m.editingItem.Deadline = nil
|
if m.editingItem != nil {
|
||||||
} else {
|
m.applyDate(dateType, m.datepicker.Time)
|
||||||
m.editingItem.Scheduled = nil
|
}
|
||||||
}
|
return m.closeDateMode()
|
||||||
|
case tea.KeyEsc:
|
||||||
// Remove property line from notes
|
m.setStatus("Cancelled")
|
||||||
var filteredNotes []string
|
return m.closeDateMode()
|
||||||
for _, note := range m.editingItem.Notes {
|
case tea.KeyRunes:
|
||||||
trimmedNote := strings.TrimSpace(note)
|
if len(msg.Runes) == 1 {
|
||||||
if !strings.HasPrefix(trimmedNote, prefixDate) {
|
r := msg.Runes[0]
|
||||||
filteredNotes = append(filteredNotes, note)
|
if r == 'x' {
|
||||||
}
|
if m.editingItem != nil {
|
||||||
}
|
m.clearDate(dateType)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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)
|
m.textinput, cmd = m.textinput.Update(msg)
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue