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 | | `i` | Clock in |
| `o` | Clock out | | `o` | Clock out |
| `d` | Set deadline | | `d` | Set deadline |
| `S` | Set scheduled date |
| `p` | Set priority | | `p` | Set priority |
| `e` | Set effort | | `e` | Set effort |
| `r` | Toggle reorder mode | | `r` | Toggle reorder mode |

View file

@ -41,6 +41,7 @@ type KeybindingsConfig struct {
ClockIn []string `toml:"clock_in"` ClockIn []string `toml:"clock_in"`
ClockOut []string `toml:"clock_out"` ClockOut []string `toml:"clock_out"`
SetDeadline []string `toml:"set_deadline"` SetDeadline []string `toml:"set_deadline"`
SetScheduled []string `toml:"set_scheduled"`
SetPriority []string `toml:"set_priority"` SetPriority []string `toml:"set_priority"`
SetEffort []string `toml:"set_effort"` SetEffort []string `toml:"set_effort"`
Help []string `toml:"help"` Help []string `toml:"help"`
@ -124,6 +125,7 @@ func DefaultConfig() *Config {
ClockIn: []string{"i"}, ClockIn: []string{"i"},
ClockOut: []string{"o"}, ClockOut: []string{"o"},
SetDeadline: []string{"d"}, SetDeadline: []string{"d"},
SetScheduled: []string{"S"},
SetPriority: []string{"p"}, SetPriority: []string{"p"},
SetEffort: []string{"e"}, SetEffort: []string{"e"},
Help: []string{"?"}, Help: []string{"?"},
@ -312,6 +314,9 @@ func (c *Config) fillDefaults() {
if len(c.Keybindings.SetDeadline) == 0 { if len(c.Keybindings.SetDeadline) == 0 {
c.Keybindings.SetDeadline = defaults.Keybindings.SetDeadline c.Keybindings.SetDeadline = defaults.Keybindings.SetDeadline
} }
if len(c.Keybindings.SetScheduled) == 0 {
c.Keybindings.SetScheduled = defaults.Keybindings.SetScheduled
}
if len(c.Keybindings.SetPriority) == 0 { if len(c.Keybindings.SetPriority) == 0 {
c.Keybindings.SetPriority = defaults.Keybindings.SetPriority c.Keybindings.SetPriority = defaults.Keybindings.SetPriority
} }
@ -567,6 +572,7 @@ func (c *Config) GetAllKeybindings() map[string][]string {
"clock_in": c.Keybindings.ClockIn, "clock_in": c.Keybindings.ClockIn,
"clock_out": c.Keybindings.ClockOut, "clock_out": c.Keybindings.ClockOut,
"set_deadline": c.Keybindings.SetDeadline, "set_deadline": c.Keybindings.SetDeadline,
"set_scheduled": c.Keybindings.SetScheduled,
"set_priority": c.Keybindings.SetPriority, "set_priority": c.Keybindings.SetPriority,
"set_effort": c.Keybindings.SetEffort, "set_effort": c.Keybindings.SetEffort,
"help": c.Keybindings.Help, "help": c.Keybindings.Help,

View file

@ -22,6 +22,7 @@ const (
modeCapture modeCapture
modeAddSubTask modeAddSubTask
modeSetDeadline modeSetDeadline
modeSetScheduled
modeSetPriority modeSetPriority
modeSetEffort modeSetEffort
modeHelp modeHelp

View file

@ -31,6 +31,7 @@ type keyMap struct {
ClockIn key.Binding ClockIn key.Binding
ClockOut key.Binding ClockOut key.Binding
SetDeadline key.Binding SetDeadline key.Binding
SetScheduled key.Binding
SetPriority key.Binding SetPriority key.Binding
SetEffort key.Binding SetEffort key.Binding
Settings key.Binding Settings key.Binding
@ -126,6 +127,10 @@ func newKeyMapFromConfig(cfg *config.Config) keyMap {
key.WithKeys(kb.SetDeadline...), key.WithKeys(kb.SetDeadline...),
key.WithHelp(formatKeyHelp(kb.SetDeadline), "set deadline"), key.WithHelp(formatKeyHelp(kb.SetDeadline), "set deadline"),
), ),
SetScheduled: key.NewBinding(
key.WithKeys(kb.SetScheduled...),
key.WithHelp(formatKeyHelp(kb.SetScheduled), "set scheduled"),
),
SetPriority: key.NewBinding( SetPriority: key.NewBinding(
key.WithKeys(kb.SetPriority...), key.WithKeys(kb.SetPriority...),
key.WithHelp(formatKeyHelp(kb.SetPriority), "set priority"), 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.Up, k.Down, k.Left, k.Right,
k.ToggleFold, k.EditNotes, k.ToggleReorder, k.ToggleFold, k.EditNotes, k.ToggleReorder,
k.Capture, k.AddSubTask, k.Delete, k.Save, 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, 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) return m.updateAddSubTask(msg)
case modeSetDeadline: case modeSetDeadline:
return m.updateSetDeadline(msg) return m.updateSetDeadline(msg)
case modeSetScheduled:
return m.updateSetScheduled(msg)
case modeSetPriority: case modeSetPriority:
return m.updateSetPriority(msg) return m.updateSetPriority(msg)
case modeSetEffort: case modeSetEffort:
@ -281,6 +283,17 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, textinput.Blink 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): case key.Matches(msg, m.keys.SetPriority):
items := m.getVisibleItems() items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) { 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) { func (m uiModel) updateSetDeadline(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd return m.updateSetDate(msg, "DEADLINE")
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) // parseDateInput parses date input like "2024-01-15" or "+3" (3 days from now)
return m, cmd func parseDateInput(input string) (time.Time, error) {
}
// parseDeadlineInput parses deadline input like "2024-01-15" or "+3" (3 days from now)
func parseDeadlineInput(input string) (time.Time, error) {
// Check if it's a relative date (+N days) // Check if it's a relative date (+N days)
if strings.HasPrefix(input, "+") { if strings.HasPrefix(input, "+") {
daysStr := strings.TrimPrefix(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) 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) { func (m uiModel) updateSetPriority(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:

View file

@ -78,6 +78,8 @@ func (m uiModel) View() string {
return m.viewAddSubTask() return m.viewAddSubTask()
case modeSetDeadline: case modeSetDeadline:
return m.viewSetDeadline() return m.viewSetDeadline()
case modeSetScheduled:
return m.viewSetScheduled()
case modeSetPriority: case modeSetPriority:
return m.viewSetPriority() return m.viewSetPriority()
case modeSetEffort: case modeSetEffort:
@ -379,6 +381,14 @@ func (m uiModel) viewAddSubTask() string {
} }
func (m uiModel) viewSetDeadline() 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(). dialogStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("141")). BorderForeground(lipgloss.Color("141")).
@ -386,7 +396,7 @@ func (m uiModel) viewSetDeadline() string {
Width(60) Width(60)
var content strings.Builder var content strings.Builder
content.WriteString(m.styles.titleStyle.Render("Set Deadline")) content.WriteString(m.styles.titleStyle.Render(title))
content.WriteString("\n") content.WriteString("\n")
if m.editingItem != nil { if m.editingItem != nil {
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title))) 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("\n\n")
content.WriteString(m.styles.statusStyle.Render("Examples: 2025-12-31, +7 (7 days from now)")) content.WriteString(m.styles.statusStyle.Render("Examples: 2025-12-31, +7 (7 days from now)"))
content.WriteString("\n") 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("\n")
content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel")) 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} 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} itemBindings := []key.Binding{m.keys.ToggleFold, m.keys.EditNotes, m.keys.CycleState}
taskBindings := []key.Binding{m.keys.Capture, m.keys.AddSubTask, m.keys.Delete} 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} 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} viewBindings := []key.Binding{m.keys.ToggleView, m.keys.Settings, m.keys.Save, m.keys.Help, m.keys.Quit}