From fce607e29dc00a5afff47931604abb196df88c25 Mon Sep 17 00:00:00 2001 From: Vitaliy Sh <107392493+vtshly@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:29:23 +0400 Subject: [PATCH] feat: SetScheduled (#17) --- README.md | 1 + internal/config/config.go | 6 ++ internal/ui/app.go | 45 ++++----- internal/ui/keybindings.go | 7 +- internal/ui/modes.go | 195 +++++++++++++++++++++++-------------- internal/ui/views.go | 16 ++- 6 files changed, 169 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index baad520..fd53cc3 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ Feel free to fork and create a pull request if there's any features missing for | `i` | Clock in | | `o` | Clock out | | `d` | Set deadline | +| `S` | Set scheduled date | | `p` | Set priority | | `e` | Set effort | | `r` | Toggle reorder mode | diff --git a/internal/config/config.go b/internal/config/config.go index 71380e6..67b1030 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,6 +41,7 @@ type KeybindingsConfig struct { ClockIn []string `toml:"clock_in"` ClockOut []string `toml:"clock_out"` SetDeadline []string `toml:"set_deadline"` + SetScheduled []string `toml:"set_scheduled"` SetPriority []string `toml:"set_priority"` SetEffort []string `toml:"set_effort"` Help []string `toml:"help"` @@ -124,6 +125,7 @@ func DefaultConfig() *Config { ClockIn: []string{"i"}, ClockOut: []string{"o"}, SetDeadline: []string{"d"}, + SetScheduled: []string{"S"}, SetPriority: []string{"p"}, SetEffort: []string{"e"}, Help: []string{"?"}, @@ -312,6 +314,9 @@ func (c *Config) fillDefaults() { if len(c.Keybindings.SetDeadline) == 0 { c.Keybindings.SetDeadline = defaults.Keybindings.SetDeadline } + if len(c.Keybindings.SetScheduled) == 0 { + c.Keybindings.SetScheduled = defaults.Keybindings.SetScheduled + } if len(c.Keybindings.SetPriority) == 0 { c.Keybindings.SetPriority = defaults.Keybindings.SetPriority } @@ -567,6 +572,7 @@ func (c *Config) GetAllKeybindings() map[string][]string { "clock_in": c.Keybindings.ClockIn, "clock_out": c.Keybindings.ClockOut, "set_deadline": c.Keybindings.SetDeadline, + "set_scheduled": c.Keybindings.SetScheduled, "set_priority": c.Keybindings.SetPriority, "set_effort": c.Keybindings.SetEffort, "help": c.Keybindings.Help, diff --git a/internal/ui/app.go b/internal/ui/app.go index c139fe3..b19e202 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -22,6 +22,7 @@ const ( modeCapture modeAddSubTask modeSetDeadline + modeSetScheduled modeSetPriority modeSetEffort modeHelp @@ -31,28 +32,28 @@ const ( ) type uiModel struct { - orgFile *model.OrgFile - cursor int - scrollOffset int // Track the scroll position - helpScroll int // Track scroll position in help mode - mode viewMode - help help.Model - keys keyMap - styles styleMap - config *config.Config - width int - height int - statusMsg string - statusExpiry time.Time - editingItem *model.Item - textarea textarea.Model - textinput textinput.Model - itemToDelete *model.Item - reorderMode bool - settingsCursor int // Cursor position in settings view - settingsScroll int // Scroll position in settings view - settingsSection settingsSection // Current settings section/tab - captureCursor int // Store cursor position when entering capture mode + orgFile *model.OrgFile + cursor int + scrollOffset int // Track the scroll position + helpScroll int // Track scroll position in help mode + mode viewMode + help help.Model + keys keyMap + styles styleMap + config *config.Config + width int + height int + statusMsg string + statusExpiry time.Time + editingItem *model.Item + textarea textarea.Model + textinput textinput.Model + itemToDelete *model.Item + reorderMode bool + settingsCursor int // Cursor position in settings view + settingsScroll int // Scroll position in settings view + settingsSection settingsSection // Current settings section/tab + captureCursor int // Store cursor position when entering capture mode } func InitialModel(orgFile *model.OrgFile, cfg *config.Config, captureMode bool, captureText string) uiModel { diff --git a/internal/ui/keybindings.go b/internal/ui/keybindings.go index 9bc160c..9a5cef0 100644 --- a/internal/ui/keybindings.go +++ b/internal/ui/keybindings.go @@ -31,6 +31,7 @@ type keyMap struct { ClockIn key.Binding ClockOut key.Binding SetDeadline key.Binding + SetScheduled key.Binding SetPriority key.Binding SetEffort key.Binding Settings key.Binding @@ -126,6 +127,10 @@ func newKeyMapFromConfig(cfg *config.Config) keyMap { key.WithKeys(kb.SetDeadline...), key.WithHelp(formatKeyHelp(kb.SetDeadline), "set deadline"), ), + SetScheduled: key.NewBinding( + key.WithKeys(kb.SetScheduled...), + key.WithHelp(formatKeyHelp(kb.SetScheduled), "set scheduled"), + ), SetPriority: key.NewBinding( key.WithKeys(kb.SetPriority...), key.WithHelp(formatKeyHelp(kb.SetPriority), "set priority"), @@ -195,7 +200,7 @@ func (k keyMap) getAllBindings() []key.Binding { k.Up, k.Down, k.Left, k.Right, k.ToggleFold, k.EditNotes, k.ToggleReorder, k.Capture, k.AddSubTask, k.Delete, k.Save, - k.ClockIn, k.ClockOut, k.SetDeadline, k.SetPriority, k.SetEffort, + k.ClockIn, k.ClockOut, k.SetDeadline, k.SetScheduled, k.SetPriority, k.SetEffort, k.TagItem, k.Settings, k.ToggleView, k.Help, k.Quit, } } diff --git a/internal/ui/modes.go b/internal/ui/modes.go index e4df26d..89a5e33 100644 --- a/internal/ui/modes.go +++ b/internal/ui/modes.go @@ -26,6 +26,8 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateAddSubTask(msg) case modeSetDeadline: return m.updateSetDeadline(msg) + case modeSetScheduled: + return m.updateSetScheduled(msg) case modeSetPriority: return m.updateSetPriority(msg) case modeSetEffort: @@ -281,6 +283,17 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, textinput.Blink } + case key.Matches(msg, m.keys.SetScheduled): + items := m.getVisibleItems() + if len(items) > 0 && m.cursor < len(items) { + m.editingItem = items[m.cursor] + m.mode = modeSetScheduled + m.textinput.SetValue("") + m.textinput.Placeholder = "YYYY-MM-DD or +N (days from today)" + m.textinput.Focus() + return m, textinput.Blink + } + case key.Matches(msg, m.keys.SetPriority): items := m.getVisibleItems() if len(items) > 0 && m.cursor < len(items) { @@ -521,83 +534,11 @@ func (m uiModel) updateAddSubTask(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m uiModel) updateSetDeadline(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.textinput.Width = 50 - - case tea.KeyMsg: - switch msg.Type { - case tea.KeyEnter: - input := strings.TrimSpace(m.textinput.Value()) - if m.editingItem != nil { - if input == "" { - // Empty input clears the deadline - m.editingItem.Deadline = nil - // Remove DEADLINE line from notes (only lines starting with DEADLINE:) - var filteredNotes []string - for _, note := range m.editingItem.Notes { - trimmedNote := strings.TrimSpace(note) - if !strings.HasPrefix(trimmedNote, "DEADLINE:") { - filteredNotes = append(filteredNotes, note) - } - } - m.editingItem.Notes = filteredNotes - m.setStatus("Deadline cleared!") - } else { - deadline, err := parseDeadlineInput(input) - if err != nil { - m.setStatus(fmt.Sprintf("Invalid date: %v", err)) - } else { - m.editingItem.Deadline = &deadline - // Also update or add DEADLINE line in notes - updatedNotes := false - for i, note := range m.editingItem.Notes { - trimmedNote := strings.TrimSpace(note) - if strings.HasPrefix(trimmedNote, "DEADLINE:") { - m.editingItem.Notes[i] = fmt.Sprintf("DEADLINE: <%s>", parser.FormatOrgDate(deadline)) - updatedNotes = true - break - } - } - // If DEADLINE wasn't in notes, it will be added by writeItem - if !updatedNotes { - // Remove old deadline lines just to be safe - var filteredNotes []string - for _, note := range m.editingItem.Notes { - trimmedNote := strings.TrimSpace(note) - if !strings.HasPrefix(trimmedNote, "DEADLINE:") { - filteredNotes = append(filteredNotes, note) - } - } - m.editingItem.Notes = filteredNotes - } - m.setStatus("Deadline set!") - } - } - } - 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.textinput, cmd = m.textinput.Update(msg) - return m, cmd + return m.updateSetDate(msg, "DEADLINE") } -// parseDeadlineInput parses deadline input like "2024-01-15" or "+3" (3 days from now) -func parseDeadlineInput(input string) (time.Time, error) { +// parseDateInput parses date input like "2024-01-15" or "+3" (3 days from now) +func parseDateInput(input string) (time.Time, error) { // Check if it's a relative date (+N days) if strings.HasPrefix(input, "+") { daysStr := strings.TrimPrefix(input, "+") @@ -625,6 +566,110 @@ func parseDeadlineInput(input string) (time.Time, error) { return time.Time{}, fmt.Errorf("unable to parse date: %s (use YYYY-MM-DD or +N)", input) } +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 + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.textinput.Width = 50 + + 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 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) + } + } + } + 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.textinput, cmd = m.textinput.Update(msg) + return m, cmd +} + func (m uiModel) updateSetPriority(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: diff --git a/internal/ui/views.go b/internal/ui/views.go index 185ec77..5c2772d 100644 --- a/internal/ui/views.go +++ b/internal/ui/views.go @@ -78,6 +78,8 @@ func (m uiModel) View() string { return m.viewAddSubTask() case modeSetDeadline: return m.viewSetDeadline() + case modeSetScheduled: + return m.viewSetScheduled() case modeSetPriority: return m.viewSetPriority() case modeSetEffort: @@ -379,6 +381,14 @@ func (m uiModel) viewAddSubTask() string { } func (m uiModel) viewSetDeadline() string { + return m.viewSetDate("Set Deadline", "Leave empty to clear deadline") +} + +func (m uiModel) viewSetScheduled() string { + return m.viewSetDate("Set Scheduled Date", "Leave empty to clear scheduled date") +} + +func (m uiModel) viewSetDate(title, helpMsg string) string { dialogStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("141")). @@ -386,7 +396,7 @@ func (m uiModel) viewSetDeadline() string { Width(60) var content strings.Builder - content.WriteString(m.styles.titleStyle.Render("Set Deadline")) + content.WriteString(m.styles.titleStyle.Render(title)) content.WriteString("\n") if m.editingItem != nil { content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title))) @@ -396,7 +406,7 @@ func (m uiModel) viewSetDeadline() string { content.WriteString("\n\n") content.WriteString(m.styles.statusStyle.Render("Examples: 2025-12-31, +7 (7 days from now)")) content.WriteString("\n") - content.WriteString(m.styles.statusStyle.Render("Leave empty to clear deadline")) + content.WriteString(m.styles.statusStyle.Render(helpMsg)) content.WriteString("\n") content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel")) @@ -488,7 +498,7 @@ func (m uiModel) viewHelp() string { navigationBindings := []key.Binding{m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right} itemBindings := []key.Binding{m.keys.ToggleFold, m.keys.EditNotes, m.keys.CycleState} taskBindings := []key.Binding{m.keys.Capture, m.keys.AddSubTask, m.keys.Delete} - timeBindings := []key.Binding{m.keys.ClockIn, m.keys.ClockOut, m.keys.SetDeadline, m.keys.SetEffort} + timeBindings := []key.Binding{m.keys.ClockIn, m.keys.ClockOut, m.keys.SetDeadline, m.keys.SetScheduled, m.keys.SetEffort} organizationBindings := []key.Binding{m.keys.SetPriority, m.keys.TagItem, m.keys.ShiftUp, m.keys.ShiftDown, m.keys.ToggleReorder} viewBindings := []key.Binding{m.keys.ToggleView, m.keys.Settings, m.keys.Save, m.keys.Help, m.keys.Quit}