feat: SetScheduled (#17)

This commit is contained in:
Vitaliy Sh 2026-02-10 17:29:23 +04:00 committed by GitHub
parent c858e70d07
commit fce607e29d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 169 additions and 101 deletions

View file

@ -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 |

View file

@ -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,

View file

@ -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 {

View file

@ -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,
}
}

View file

@ -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:

View file

@ -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}