diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yaml b/.github/DISCUSSION_TEMPLATE/q-a.yaml index a65fd46..0e657eb 100644 --- a/.github/DISCUSSION_TEMPLATE/q-a.yaml +++ b/.github/DISCUSSION_TEMPLATE/q-a.yaml @@ -1,4 +1,4 @@ -title: 'Q&A' +title: "Q&A" labels: [] body: - type: markdown diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index baae06b..0796c39 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -1,14 +1,13 @@ name: Bug Report description: Report a bug -title: 'bug: ' +title: "bug: " labels: [bug] body: - type: checkboxes attributes: label: Prerequisites options: - - label: - I have searched [existing + - label: I have searched [existing issues](https://github.com/barrettruth/pending.nvim/issues) required: true - label: I have updated to the latest version @@ -16,16 +15,16 @@ body: - type: textarea attributes: - label: 'Neovim version' - description: 'Output of `nvim --version`' + label: "Neovim version" + description: "Output of `nvim --version`" render: text validations: required: true - type: input attributes: - label: 'Operating system' - placeholder: 'e.g. Arch Linux, macOS 15, Ubuntu 24.04' + label: "Operating system" + placeholder: "e.g. Arch Linux, macOS 15, Ubuntu 24.04" validations: required: true @@ -49,8 +48,8 @@ body: - type: textarea attributes: - label: 'Health check' - description: 'Output of `:checkhealth task`' + label: "Health check" + description: "Output of `:checkhealth task`" render: text - type: textarea diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index cabb27c..f4c02eb 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -1,14 +1,13 @@ name: Feature Request description: Suggest a feature -title: 'feat: ' +title: "feat: " labels: [enhancement] body: - type: checkboxes attributes: label: Prerequisites options: - - label: - I have searched [existing + - label: I have searched [existing issues](https://github.com/barrettruth/pending.nvim/issues) required: true diff --git a/.github/workflows/luarocks.yaml b/.github/workflows/luarocks.yaml index 9b6664e..9f934a5 100644 --- a/.github/workflows/luarocks.yaml +++ b/.github/workflows/luarocks.yaml @@ -3,7 +3,7 @@ name: luarocks on: push: tags: - - 'v*' + - "v*" jobs: quality: diff --git a/README.md b/README.md index 98e14d3..df7f3dd 100644 --- a/README.md +++ b/README.md @@ -2,145 +2,30 @@ Edit tasks like text. `:w` saves them. -A buffer-centric task manager for Neovim. Tasks live in a plain buffer — add -with `o`, delete with `dd`, reorder with `dd`/`p`, rename by editing. Write the -buffer and the diff is computed against a JSON store. No UI chrome, no floating -windows, no abstractions between you and your tasks. + -## How it works +## Requirements -``` -School - ! Read chapter 5 Feb 28 - Submit homework Feb 25 +- Neovim 0.10+ +- (Optionally) `curl` and `openssl` for Google Calendar and Google Task sync -Errands - Buy groceries Mar 01 - Clean apartment -``` +## Installation -Category headers sit at column 0. Tasks are indented below them. `!` marks -priority. Due dates appear as right-aligned virtual text. Done tasks get -strikethrough. Everything you see is editable buffer text — the IDs are -concealed, and metadata is parsed from inline syntax on save. - -## Install +Install with your package manager of choice or via +[luarocks](https://luarocks.org/modules/barrettruth/pending.nvim): ``` luarocks install pending.nvim ``` -**lazy.nvim:** - -```lua -{ 'barrettruth/pending.nvim' } -``` - -Requires Neovim 0.10+. No external dependencies for local use. Google Calendar -sync requires `curl` and `openssl`. - -## Usage - -`:Pending` opens the task buffer. From there, it's just vim: - -| Key | Action | -| --------- | ------------------------------- | -| `o` / `O` | Add a new task | -| `dd` | Delete a task (on `:w`) | -| `p` | Paste (duplicates get new IDs) | -| `:w` | Save all changes | -| `` | Toggle complete (immediate) | -| `` | Switch category / priority view | -| `g?` | Show keybind help | - -### Inline metadata - -Type metadata tokens at the end of a task line before saving: - -``` -Buy milk due:2026-03-15 cat:Errands -``` - -On `:w`, the date and category are extracted. The description becomes `Buy milk`, -the due date renders as virtual text, and the task moves under the `Errands` -header. - -### Quick add - -```vim -:Pending add Buy groceries due:2026-03-15 -:Pending add School: Submit homework -``` - -### Archive - -```vim -:Pending archive " purge done tasks older than 30 days -:Pending archive 7 " purge done tasks older than 7 days -``` - -## Configuration - -No `setup()` call required. Set `vim.g.pending` before the plugin loads: - -```lua -vim.g.pending = { - data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', - default_view = 'category', -- 'category' or 'priority' - default_category = 'Inbox', - date_format = '%b %d', -- strftime format for virtual text - date_syntax = 'due', -- inline token name (e.g. 'by' for by:2026-03-15) -} -``` - -All fields are optional. Absent keys use the defaults shown above. - -## Google Calendar sync - -One-way push of tasks with due dates to a dedicated Google Calendar as all-day -events. - -```lua -vim.g.pending = { - gcal = { - calendar = 'Tasks', - credentials_path = '/path/to/client_secret.json', - }, -} -``` - -```vim -:Pending sync -``` - -On first run, a browser window opens for OAuth consent. The refresh token is -stored at `stdpath('data')/pending/gcal_tokens.json`. Completed or deleted tasks -have their calendar events removed. Due date changes update events in place. - -## Mappings - -The plugin defines `` mappings for custom keybinds: - -```lua -vim.keymap.set('n', 't', '(pending-open)') -vim.keymap.set('n', 'T', '(pending-toggle)') -``` - -| Plug mapping | Action | -| -------------------------- | -------------------- | -| `(pending-open)` | Open task buffer | -| `(pending-toggle)` | Toggle complete | -| `(pending-view)` | Switch view | -| `(pending-priority)` | Toggle priority flag | -| `(pending-date)` | Prompt for due date | - -## Data format - -Tasks are stored as JSON at `stdpath('data')/pending/tasks.json`. The schema is -versioned and forward-compatible — unknown fields are preserved on round-trip. - ## Documentation ```vim -:checkhealth pending +:help pending.nvim ``` + +## Acknowledgements + +- [dooing](https://github.com/atiladefreitas/dooing) +- [todo-comments.nvim](https://github.com/folke/todo-comments.nvim) +- [todotxt.nvim](https://github.com/arnarg/todotxt.nvim) diff --git a/doc/pending.txt b/doc/pending.txt index 4eb8e40..66882b9 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -30,13 +30,16 @@ concealed tokens and are never visible during editing. Features: ~ - Oil-style buffer editing: standard Vim motions for all task operations -- Inline metadata syntax: `due:` and `cat:` tokens parsed on `:w` -- Relative date input: `today`, `tomorrow`, `+Nd`, weekday names +- Inline metadata syntax: `due:`, `cat:`, and `rec:` tokens parsed on `:w` +- Relative date input: `today`, `tomorrow`, `+Nd`, `+Nw`, `+Nm`, weekday + names, month names, ordinals, and more +- Recurring tasks with automatic next-date spawning on completion - Two views: category (default) and priority flat list - Multi-level undo (up to 20 `:w` saves, session-only) - Quick-add from the command line with `:Pending add` - Quickfix list of overdue/due-today tasks via `:Pending due` - Foldable category sections (`zc`/`zo`) in category view +- Omnifunc completion for `cat:`, `due:`, and `rec:` tokens (``) - Google Calendar one-way push via OAuth PKCE ============================================================================== @@ -95,20 +98,18 @@ parsed from the right and consumed until a non-metadata token is reached. Supported tokens: ~ `due:YYYY-MM-DD` Set a due date using an absolute date. - `due:today` Resolve to today's date. - `due:tomorrow` Resolve to tomorrow's date. - `due:+Nd` Resolve to N days from today (e.g. `due:+3d`). - `due:mon` Resolve to the next occurrence of that weekday. - Supported: `sun` `mon` `tue` `wed` `thu` `fri` `sat` + `due:` Resolve a named date (see |pending-dates| below). `cat:Name` Move the task to the named category on save. + `rec:` Set a recurrence rule (see |pending-recurrence|). The token name for due dates defaults to `due` and is configurable via -`date_syntax` in |pending-config|. If `date_syntax` is set to `by`, write -`by:2026-03-15` instead. +`date_syntax` in |pending-config|. The token name for recurrence defaults to +`rec` and is configurable via `recur_syntax`. Example: > Buy milk due:2026-03-15 cat:Errands + Take out trash due:monday rec:weekly < On `:w`, the description becomes `Buy milk`, the due date is stored as @@ -116,8 +117,87 @@ On `:w`, the description becomes `Buy milk`, the due date is stored as placed under the `Errands` category header. Parsing stops at the first token that is not a recognised metadata token. -Repeated tokens of the same type also stop parsing — only one `due:` and one -`cat:` per task line are consumed. +Repeated tokens of the same type also stop parsing — only one `due:`, one +`cat:`, and one `rec:` per task line are consumed. + +Omnifunc completion is available for all three token types. In insert mode, +type `due:`, `cat:`, or `rec:` and press `` to see suggestions. + +============================================================================== +DATE INPUT *pending-dates* + +Named dates can be used anywhere a date is accepted: the `due:` inline +token, the `D` prompt, and `:Pending add`. + + Token Resolves to ~ + ----- ----------- + `today` Today's date + `tomorrow` Tomorrow's date + `yesterday` Yesterday's date + `eod` Today (end of day semantics) + `+Nd` N days from today (e.g. `+3d`) + `+Nw` N weeks from today (e.g. `+2w`) + `+Nm` N months from today (e.g. `+1m`) + `-Nd` N days ago (e.g. `-2d`) + `-Nw` N weeks ago (e.g. `-1w`) + `mon`–`sun` Next occurrence of that weekday + `jan`–`dec` 1st of next occurrence of that month + `1st`–`31st` Next occurrence of that day-of-month + `sow` / `eow` Monday / Sunday of current week + `som` / `eom` First / last day of current month + `soq` / `eoq` First / last day of current quarter + `soy` / `eoy` January 1 / December 31 of current year + `later` / `someday` Sentinel date (default: `9999-12-30`) + +============================================================================== +RECURRENCE *pending-recurrence* + +Tasks can recur on a schedule. Add a `rec:` token to set recurrence: > + + - [ ] Take out trash due:monday rec:weekly + - [ ] Pay rent due:2026-03-01 rec:monthly + - [ ] Standup due:tomorrow rec:weekdays +< + +When a recurring task is marked done with ``: +1. The current task stays as done (preserving history). +2. A new pending task is created with the same description, category, + priority, and recurrence — with the due date advanced to the next + occurrence. + +Shorthand patterns: ~ + + Pattern Meaning ~ + ------- ------- + `daily` Every day + `weekdays` Monday through Friday + `weekly` Every week + `biweekly` Every 2 weeks (alias: `2w`) + `monthly` Every month + `quarterly` Every 3 months (alias: `3m`) + `yearly` Every year (alias: `annual`) + `Nd` Every N days (e.g. `3d`) + `Nw` Every N weeks (e.g. `2w`) + `Nm` Every N months (e.g. `6m`) + `Ny` Every N years (e.g. `2y`) + +For patterns the shorthand cannot express, use a raw RRULE fragment: > + rec:FREQ=MONTHLY;BYDAY=1MO +< + +Completion-based recurrence: ~ *pending-recur-completion* +By default, recurrence is schedule-based: the next due date advances from the +original schedule, skipping to the next future occurrence. Prefix the pattern +with `!` for completion-based mode, where the next due date advances from the +completion date: > + rec:!weekly +< +Schedule-based is like org-mode `++`; completion-based is like `.+`. + +Google Calendar: ~ +Recurrence patterns map directly to iCalendar RRULE strings for future GCal +sync support. Completion-based recurrence cannot be synced (it is inherently +local). ============================================================================== COMMANDS *pending-commands* @@ -135,6 +215,7 @@ COMMANDS *pending-commands* :Pending add Buy groceries due:2026-03-15 :Pending add School: Submit homework :Pending add Errands: Pick up dry cleaning due:fri + :Pending add Work: standup due:tomorrow rec:weekdays < If the buffer is currently open it is re-rendered after the add. @@ -169,27 +250,34 @@ MAPPINGS *pending-mappings* The following keys are set buffer-locally when the task buffer opens. They are active only in the `pending://` buffer. -Buffer-local keys: ~ +Buffer-local keys are configured via the `keymaps` table in |pending-config|. +The defaults are shown below. Set any key to `false` to disable it. + +Default buffer-local keys: ~ Key Action ~ ------- ------------------------------------------------ - `` Toggle complete / uncomplete the task at cursor - `!` Toggle the priority flag on the task at cursor - `D` Prompt for a due date on the task at cursor - `` Switch between category view and priority view - `U` Undo the last `:w` save - `g?` Show a help popup with available keys + `q` Close the task buffer (`close`) + `` Toggle complete / uncomplete (`toggle`) + `!` Toggle the priority flag (`priority`) + `D` Prompt for a due date (`date`) + `` Switch between category / priority view (`view`) + `U` Undo the last `:w` save (`undo`) + `o` Insert a new task line below (`open_line`) + `O` Insert a new task line above (`open_line_above`) `zc` Fold the current category section (category view only) `zo` Unfold the current category section (category view only) -`o` and `O` are overridden to insert a correctly-formatted blank task line -at the position below or above the cursor rather than using standard Vim -indentation. `dd`, `p`, `P`, and `:w` work as expected. +`dd`, `p`, `P`, and `:w` work as standard Vim operations. *(pending-open)* (pending-open) Open the task buffer. Maps to |:Pending| with no arguments. + *(pending-close)* +(pending-close) + Close the task buffer window. + *(pending-toggle)* (pending-toggle) Toggle complete / uncomplete for the task under the cursor. @@ -206,6 +294,18 @@ indentation. `dd`, `p`, `P`, and `:w` work as expected. (pending-view) Switch between category view and priority view. + *(pending-undo)* +(pending-undo) + Undo the last `:w` save. + + *(pending-open-line)* +(pending-open-line) + Insert a correctly-formatted blank task line below the cursor. + + *(pending-open-line-above)* +(pending-open-line-above) + Insert a correctly-formatted blank task line above the cursor. + Example configuration: >lua vim.keymap.set('n', 't', '(pending-open)') vim.keymap.set('n', 'T', '(pending-toggle)') @@ -242,7 +342,19 @@ loads: >lua default_category = 'Inbox', date_format = '%b %d', date_syntax = 'due', + recur_syntax = 'rec', + someday_date = '9999-12-30', category_order = {}, + keymaps = { + close = 'q', + toggle = '', + view = '', + priority = '!', + date = 'D', + undo = 'U', + open_line = 'o', + open_line_above = 'O', + }, gcal = { calendar = 'Tasks', credentials_path = '/path/to/client_secret.json', @@ -278,12 +390,28 @@ Fields: ~ this to use a different keyword, for example `'by'` to write `by:2026-03-15` instead of `due:2026-03-15`. + {recur_syntax} (string, default: 'rec') + The token name for inline recurrence metadata. Change + this to use a different keyword, for example + `'repeat'` to write `repeat:weekly`. + + {someday_date} (string, default: '9999-12-30') + The date that `later` and `someday` resolve to. This + acts as a "no date" sentinel for GTD-style workflows. + {category_order} (string[], default: {}) Ordered list of category names. In category view, categories that appear in this list are shown in the given order. Categories not in the list are appended after the ordered ones in their natural order. + {keymaps} (table, default: see below) *pending.Keymaps* + Buffer-local key bindings. Each field maps an action + name to a key string. Set a field to `false` to + disable that binding. Unset fields use the default. + See |pending-mappings| for the full list of actions + and their default keys. + {gcal} (table, default: nil) Google Calendar sync configuration. See |pending.GcalConfig|. Omit this field entirely to @@ -371,6 +499,11 @@ PendingDone Applied to the text of completed tasks. PendingPriority Applied to the `! ` priority marker on priority tasks. Default: links to `DiagnosticWarn`. + *PendingRecur* +PendingRecur Applied to the recurrence indicator virtual text shown + alongside due dates for recurring tasks. + Default: links to `DiagnosticInfo`. + To override a group in your colorscheme or config: >lua vim.api.nvim_set_hl(0, 'PendingDue', { fg = '#aaaaaa', italic = true }) < @@ -388,6 +521,7 @@ Checks performed: ~ category, date format, date syntax) - Whether the data directory exists (warning if not yet created) - Whether the data file exists and can be parsed; reports total task count +- Validates recurrence specs on stored tasks - Whether `curl` is available (required for Google Calendar sync) - Whether `openssl` is available (required for OAuth PKCE) @@ -414,6 +548,8 @@ Task fields: ~ {category} (string) Category name. Defaults to `default_category`. {priority} (integer) `1` for priority tasks, `0` otherwise. {due} (string) ISO date string `YYYY-MM-DD`, or absent. + {recur} (string) Recurrence shorthand (e.g. `weekly`), or absent. + {recur_mode} (string) `'scheduled'` or `'completion'`, or absent. {entry} (string) ISO 8601 UTC timestamp of creation. {modified} (string) ISO 8601 UTC timestamp of last modification. {end} (string) ISO 8601 UTC timestamp of completion or deletion. diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index d11254b..14636ea 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -55,6 +55,7 @@ local function set_buf_options(bufnr) vim.bo[bufnr].swapfile = false vim.bo[bufnr].filetype = 'pending' vim.bo[bufnr].modifiable = true + vim.bo[bufnr].omnifunc = 'v:lua.require("pending.complete").omnifunc' end ---@param winid integer @@ -122,24 +123,22 @@ local function apply_extmarks(bufnr, line_meta) local row = i - 1 if m.type == 'task' then local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue' - if m.show_category then - local virt_text - if m.category and m.due then - virt_text = { { m.category .. ' ', 'PendingHeader' }, { m.due, due_hl } } - elseif m.category then - virt_text = { { m.category, 'PendingHeader' } } - elseif m.due then - virt_text = { { m.due, due_hl } } + local virt_parts = {} + if m.show_category and m.category then + table.insert(virt_parts, { m.category, 'PendingHeader' }) + end + if m.recur then + table.insert(virt_parts, { '\u{21bb} ' .. m.recur, 'PendingRecur' }) + end + if m.due then + table.insert(virt_parts, { m.due, due_hl }) + end + if #virt_parts > 0 then + for p = 1, #virt_parts - 1 do + virt_parts[p][1] = virt_parts[p][1] .. ' ' end - if virt_text then - vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { - virt_text = virt_text, - virt_text_pos = 'eol', - }) - end - elseif m.due then vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { - virt_text = { { m.due, due_hl } }, + virt_text = virt_parts, virt_text_pos = 'eol', }) end @@ -167,6 +166,7 @@ local function setup_highlights() vim.api.nvim_set_hl(0, 'PendingOverdue', { link = 'DiagnosticError', default = true }) vim.api.nvim_set_hl(0, 'PendingDone', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true }) + vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true }) end local function snapshot_folds(bufnr) diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua new file mode 100644 index 0000000..f83b6a4 --- /dev/null +++ b/lua/pending/complete.lua @@ -0,0 +1,138 @@ +local config = require('pending.config') + +---@class pending.complete +local M = {} + +---@return string +local function date_key() + return config.get().date_syntax or 'due' +end + +---@return string +local function recur_key() + return config.get().recur_syntax or 'rec' +end + +---@return string[] +local function get_categories() + local store = require('pending.store') + local seen = {} + local result = {} + for _, task in ipairs(store.active_tasks()) do + local cat = task.category + if cat and not seen[cat] then + seen[cat] = true + table.insert(result, cat) + end + end + table.sort(result) + return result +end + +---@return string[] +local function date_completions() + return { + 'today', + 'tomorrow', + 'yesterday', + '+1d', + '+2d', + '+3d', + '+1w', + '+2w', + '+1m', + 'mon', + 'tue', + 'wed', + 'thu', + 'fri', + 'sat', + 'sun', + 'eod', + 'eow', + 'eom', + 'eoq', + 'eoy', + 'sow', + 'som', + 'soq', + 'soy', + 'later', + } +end + +---@return string[] +local function recur_completions() + local recur = require('pending.recur') + local list = recur.shorthand_list() + local result = {} + for _, s in ipairs(list) do + table.insert(result, s) + end + for _, s in ipairs(list) do + table.insert(result, '!' .. s) + end + return result +end + +---@type string? +local _complete_source = nil + +---@param findstart integer +---@param base string +---@return integer|table[] +function M.omnifunc(findstart, base) + if findstart == 1 then + local line = vim.api.nvim_get_current_line() + local col = vim.api.nvim_win_get_cursor(0)[2] + local before = line:sub(1, col) + + local dk = date_key() + local rk = recur_key() + + local checks = { + { vim.pesc(dk) .. ':([%S]*)$', dk }, + { 'cat:([%S]*)$', 'cat' }, + { vim.pesc(rk) .. ':([%S]*)$', rk }, + } + + for _, check in ipairs(checks) do + local start = before:find(check[1]) + if start then + local colon_pos = before:find(':', start, true) + if colon_pos then + _complete_source = check[2] + return colon_pos + end + end + end + + _complete_source = nil + return -1 + end + + local candidates = {} + local source = _complete_source or '' + + local dk = date_key() + local rk = recur_key() + + if source == dk then + candidates = date_completions() + elseif source == 'cat' then + candidates = get_categories() + elseif source == rk then + candidates = recur_completions() + end + + local matches = {} + for _, c in ipairs(candidates) do + if base == '' or c:sub(1, #base) == base then + table.insert(matches, { word = c, menu = '[' .. source .. ']' }) + end + end + + return matches +end + +return M diff --git a/lua/pending/config.lua b/lua/pending/config.lua index b61f44a..3318b3d 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -2,14 +2,27 @@ ---@field calendar? string ---@field credentials_path? string +---@class pending.Keymaps +---@field close? string|false +---@field toggle? string|false +---@field view? string|false +---@field priority? string|false +---@field date? string|false +---@field undo? string|false +---@field open_line? string|false +---@field open_line_above? string|false + ---@class pending.Config ---@field data_path string ---@field default_view 'category'|'priority' ---@field default_category string ---@field date_format string ---@field date_syntax string +---@field recur_syntax string +---@field someday_date string ---@field category_order? string[] ---@field drawer_height? integer +---@field keymaps pending.Keymaps ---@field gcal? pending.GcalConfig ---@class pending.config @@ -22,7 +35,19 @@ local defaults = { default_category = 'Todo', date_format = '%b %d', date_syntax = 'due', + recur_syntax = 'rec', + someday_date = '9999-12-30', category_order = {}, + keymaps = { + close = 'q', + toggle = '', + view = '', + priority = '!', + date = 'D', + undo = 'U', + open_line = 'o', + open_line_above = 'O', + }, } ---@type pending.Config? diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 85f083c..bec3baa 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -10,6 +10,8 @@ local store = require('pending.store') ---@field status? string ---@field category? string ---@field due? string +---@field rec? string +---@field rec_mode? string ---@field lnum integer ---@class pending.diff @@ -48,6 +50,8 @@ function M.parse_buffer(lines) status = status, category = metadata.cat or current_category or config.get().default_category, due = metadata.due, + rec = metadata.rec, + rec_mode = metadata.rec_mode, lnum = i, }) end @@ -90,6 +94,8 @@ function M.apply(lines) category = entry.category, priority = entry.priority, due = entry.due, + recur = entry.rec, + recur_mode = entry.rec_mode, order = order_counter, }) else @@ -112,6 +118,14 @@ function M.apply(lines) task.due = entry.due changed = true end + if task.recur ~= entry.rec then + task.recur = entry.rec + changed = true + end + if task.recur_mode ~= entry.rec_mode then + task.recur_mode = entry.rec_mode + changed = true + end if entry.status and task.status ~= entry.status then task.status = entry.status if entry.status == 'done' then @@ -135,6 +149,8 @@ function M.apply(lines) category = entry.category, priority = entry.priority, due = entry.due, + recur = entry.rec, + recur_mode = entry.rec_mode, order = order_counter, }) end diff --git a/lua/pending/health.lua b/lua/pending/health.lua index 8a12da4..78311d2 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -27,6 +27,17 @@ function M.check() if load_ok then local tasks = store.tasks() vim.health.ok('Data file loaded: ' .. #tasks .. ' tasks') + local recur = require('pending.recur') + local invalid_count = 0 + for _, task in ipairs(tasks) do + if task.recur and not recur.validate(task.recur) then + invalid_count = invalid_count + 1 + vim.health.warn('Task ' .. task.id .. ' has invalid recurrence spec: ' .. task.recur) + end + end + if invalid_count == 0 then + vim.health.ok('All recurrence specs are valid') + end else vim.health.error('Failed to load data file: ' .. tostring(err)) end diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 14b9c24..216b8b3 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -50,37 +50,44 @@ end ---@param bufnr integer function M._setup_buf_mappings(bufnr) + local cfg = require('pending.config').get() + local km = cfg.keymaps local opts = { buffer = bufnr, silent = true } - vim.keymap.set('n', 'q', function() - buffer.close() - end, opts) - vim.keymap.set('n', '', function() - buffer.close() - end, opts) - vim.keymap.set('n', '', function() - M.toggle_complete() - end, opts) - vim.keymap.set('n', '', function() - buffer.toggle_view() - end, opts) - vim.keymap.set('n', 'g?', function() - M.show_help() - end, opts) - vim.keymap.set('n', '!', function() - M.toggle_priority() - end, opts) - vim.keymap.set('n', 'D', function() - M.prompt_date() - end, opts) - vim.keymap.set('n', 'U', function() - M.undo_write() - end, opts) - vim.keymap.set('n', 'o', function() - buffer.open_line(false) - end, opts) - vim.keymap.set('n', 'O', function() - buffer.open_line(true) - end, opts) + + ---@type table + local actions = { + close = function() + buffer.close() + end, + toggle = function() + M.toggle_complete() + end, + view = function() + buffer.toggle_view() + end, + priority = function() + M.toggle_priority() + end, + date = function() + M.prompt_date() + end, + undo = function() + M.undo_write() + end, + open_line = function() + buffer.open_line(false) + end, + open_line_above = function() + buffer.open_line(true) + end, + } + + for name, fn in pairs(actions) do + local key = km[name] + if key and key ~= false then + vim.keymap.set('n', key --[[@as string]], fn, opts) + end + end end ---@param bufnr integer @@ -127,6 +134,21 @@ function M.toggle_complete() if task.status == 'done' then store.update(id, { status = 'pending', ['end'] = vim.NIL }) else + if task.recur and task.due then + local recur = require('pending.recur') + local mode = task.recur_mode or 'scheduled' + local base = mode == 'completion' and os.date('%Y-%m-%d') --[[@as string]] + or task.due + local next_date = recur.next_due(base, task.recur, mode) + store.add({ + description = task.description, + category = task.category, + priority = task.priority, + due = next_date, + recur = task.recur, + recur_mode = task.recur_mode, + }) + end store.update(id, { status = 'done' }) end store.save() @@ -219,6 +241,8 @@ function M.add(text) description = description, category = metadata.cat, due = metadata.due, + recur = metadata.rec, + recur_mode = metadata.rec_mode, }) store.save() local bufnr = buffer.bufnr() @@ -317,67 +341,6 @@ function M.due() vim.cmd('copen') end -function M.show_help() - local cfg = require('pending.config').get() - local dk = cfg.date_syntax or 'due' - local lines = { - 'pending.nvim keybindings', - '', - ' Toggle complete/uncomplete', - ' Switch category/priority view', - '! Toggle urgent', - 'D Set due date', - 'U Undo last write', - 'o / O Add new task line', - 'dd Delete task line (on :w)', - 'p / P Paste (duplicates get new IDs)', - 'zc / zo Fold/unfold category (category view)', - ':w Save all changes', - '', - ':Pending add Quick-add task', - ':Pending add Cat: Quick-add with category', - ':Pending due Show overdue/due qflist', - ':Pending sync Push to Google Calendar', - ':Pending archive [days] Purge old done tasks', - ':Pending undo Undo last write', - '', - 'Inline metadata (on new lines before :w):', - ' ' .. dk .. ':YYYY-MM-DD Set due date', - ' cat:Name Set category', - '', - 'Due date input:', - ' today, tomorrow, +Nd, mon-sun', - ' Empty input clears due date', - '', - 'Highlights:', - ' PendingOverdue overdue tasks (red)', - ' PendingPriority [!] urgent tasks', - '', - 'Press q or to close', - } - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - vim.bo[buf].modifiable = false - vim.bo[buf].bufhidden = 'wipe' - local width = 54 - local height = #lines - local win = vim.api.nvim_open_win(buf, true, { - relative = 'editor', - width = width, - height = height, - col = math.floor((vim.o.columns - width) / 2), - row = math.floor((vim.o.lines - height) / 2), - style = 'minimal', - border = 'rounded', - }) - vim.keymap.set('n', 'q', function() - vim.api.nvim_win_close(win, true) - end, { buffer = buf, silent = true }) - vim.keymap.set('n', '', function() - vim.api.nvim_win_close(win, true) - end, { buffer = buf, silent = true }) -end - ---@param args string function M.command(args) if not args or args == '' then diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index ebe909a..853fa2c 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -29,6 +29,11 @@ local function date_key() return config.get().date_syntax or 'due' end +---@return string +local function recur_key() + return config.get().recur_syntax or 'rec' +end + local weekday_map = { sun = 1, mon = 2, @@ -39,14 +44,42 @@ local weekday_map = { sat = 7, } +local month_map = { + jan = 1, + feb = 2, + mar = 3, + apr = 4, + may = 5, + jun = 6, + jul = 7, + aug = 8, + sep = 9, + oct = 10, + nov = 11, + dec = 12, +} + +---@param today osdate +---@return string +local function today_str(today) + return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]] +end + ---@param text string ---@return string|nil function M.resolve_date(text) local lower = text:lower() local today = os.date('*t') --[[@as osdate]] - if lower == 'today' then - return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]] + if lower == 'today' or lower == 'eod' then + return today_str(today) + end + + if lower == 'yesterday' then + return os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day - 1 }) + ) --[[@as string]] end if lower == 'tomorrow' then @@ -56,6 +89,54 @@ function M.resolve_date(text) ) --[[@as string]] end + if lower == 'sow' then + local delta = -((today.wday - 2) % 7) + return os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + delta }) + ) --[[@as string]] + end + + if lower == 'eow' then + local delta = (1 - today.wday) % 7 + return os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + delta }) + ) --[[@as string]] + end + + if lower == 'som' then + return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = 1 })) --[[@as string]] + end + + if lower == 'eom' then + return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 })) --[[@as string]] + end + + if lower == 'soq' then + local q = math.ceil(today.month / 3) + local first_month = (q - 1) * 3 + 1 + return os.date('%Y-%m-%d', os.time({ year = today.year, month = first_month, day = 1 })) --[[@as string]] + end + + if lower == 'eoq' then + local q = math.ceil(today.month / 3) + local last_month = q * 3 + return os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 })) --[[@as string]] + end + + if lower == 'soy' then + return os.date('%Y-%m-%d', os.time({ year = today.year, month = 1, day = 1 })) --[[@as string]] + end + + if lower == 'eoy' then + return os.date('%Y-%m-%d', os.time({ year = today.year, month = 12, day = 31 })) --[[@as string]] + end + + if lower == 'later' or lower == 'someday' then + return config.get().someday_date + end + local n = lower:match('^%+(%d+)d$') if n then return os.date( @@ -70,6 +151,102 @@ function M.resolve_date(text) ) --[[@as string]] end + n = lower:match('^%+(%d+)w$') + if n then + return os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month, + day = today.day + ( + tonumber(n) --[[@as integer]] + ) * 7, + }) + ) --[[@as string]] + end + + n = lower:match('^%+(%d+)m$') + if n then + return os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month + ( + tonumber(n) --[[@as integer]] + ), + day = today.day, + }) + ) --[[@as string]] + end + + n = lower:match('^%-(%d+)d$') + if n then + return os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month, + day = today.day - ( + tonumber(n) --[[@as integer]] + ), + }) + ) --[[@as string]] + end + + n = lower:match('^%-(%d+)w$') + if n then + return os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month, + day = today.day - ( + tonumber(n) --[[@as integer]] + ) * 7, + }) + ) --[[@as string]] + end + + local ord = lower:match('^(%d+)[snrt][tdh]$') + if ord then + local day_num = tonumber(ord) --[[@as integer]] + if day_num >= 1 and day_num <= 31 then + local m, y = today.month, today.year + if today.day >= day_num then + m = m + 1 + if m > 12 then + m = 1 + y = y + 1 + end + end + local t = os.time({ year = y, month = m, day = day_num }) + local check = os.date('*t', t) --[[@as osdate]] + if check.day == day_num then + return os.date('%Y-%m-%d', t) --[[@as string]] + end + m = m + 1 + if m > 12 then + m = 1 + y = y + 1 + end + t = os.time({ year = y, month = m, day = day_num }) + check = os.date('*t', t) --[[@as osdate]] + if check.day == day_num then + return os.date('%Y-%m-%d', t) --[[@as string]] + end + return nil + end + end + + local target_month = month_map[lower] + if target_month then + local y = today.year + if today.month >= target_month then + y = y + 1 + end + return os.date('%Y-%m-%d', os.time({ year = y, month = target_month, day = 1 })) --[[@as string]] + end + local target_wday = weekday_map[lower] if target_wday then local current_wday = today.wday @@ -85,7 +262,7 @@ end ---@param text string ---@return string description ----@return { due?: string, cat?: string } metadata +---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } metadata function M.body(text) local tokens = {} for token in text:gmatch('%S+') do @@ -95,8 +272,10 @@ function M.body(text) local metadata = {} local i = #tokens local dk = date_key() + local rk = recur_key() local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$' local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$' + local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$' while i >= 1 do local token = tokens[i] @@ -131,7 +310,25 @@ function M.body(text) metadata.cat = cat_val i = i - 1 else - break + local rec_val = token:match(rec_pattern) + if rec_val then + if metadata.rec then + break + end + local recur = require('pending.recur') + local raw_spec = rec_val + if raw_spec:sub(1, 1) == '!' then + metadata.rec_mode = 'completion' + raw_spec = raw_spec:sub(2) + end + if not recur.validate(raw_spec) then + break + end + metadata.rec = raw_spec + i = i - 1 + else + break + end end end end @@ -148,7 +345,7 @@ end ---@param text string ---@return string description ----@return { due?: string, cat?: string } metadata +---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } metadata function M.command_add(text) local cat_prefix = text:match('^(%S.-):%s') if cat_prefix then diff --git a/lua/pending/recur.lua b/lua/pending/recur.lua new file mode 100644 index 0000000..c0a2091 --- /dev/null +++ b/lua/pending/recur.lua @@ -0,0 +1,166 @@ +---@class pending.RecurSpec +---@field freq 'daily'|'weekly'|'monthly'|'yearly' +---@field interval integer +---@field byday? string[] +---@field from_completion boolean +---@field _raw? string + +---@class pending.recur +local M = {} + +---@type table +local named = { + daily = { freq = 'daily', interval = 1, from_completion = false }, + weekdays = { + freq = 'weekly', + interval = 1, + byday = { 'MO', 'TU', 'WE', 'TH', 'FR' }, + from_completion = false, + }, + weekly = { freq = 'weekly', interval = 1, from_completion = false }, + biweekly = { freq = 'weekly', interval = 2, from_completion = false }, + monthly = { freq = 'monthly', interval = 1, from_completion = false }, + quarterly = { freq = 'monthly', interval = 3, from_completion = false }, + yearly = { freq = 'yearly', interval = 1, from_completion = false }, + annual = { freq = 'yearly', interval = 1, from_completion = false }, +} + +---@param spec string +---@return pending.RecurSpec? +function M.parse(spec) + local from_completion = false + local s = spec + + if s:sub(1, 1) == '!' then + from_completion = true + s = s:sub(2) + end + + local lower = s:lower() + + local base = named[lower] + if base then + return { + freq = base.freq, + interval = base.interval, + byday = base.byday, + from_completion = from_completion, + } + end + + local n, unit = lower:match('^(%d+)([dwmy])$') + if n then + local num = tonumber(n) --[[@as integer]] + if num < 1 then + return nil + end + local freq_map = { d = 'daily', w = 'weekly', m = 'monthly', y = 'yearly' } + return { + freq = freq_map[unit], + interval = num, + from_completion = from_completion, + } + end + + if s:match('^FREQ=') then + return { + freq = 'daily', + interval = 1, + from_completion = from_completion, + _raw = s, + } + end + + return nil +end + +---@param spec string +---@return boolean +function M.validate(spec) + return M.parse(spec) ~= nil +end + +---@param base_date string +---@param freq string +---@param interval integer +---@return string +local function advance_date(base_date, freq, interval) + local y, m, d = base_date:match('^(%d+)-(%d+)-(%d+)$') + local yn = tonumber(y) --[[@as integer]] + local mn = tonumber(m) --[[@as integer]] + local dn = tonumber(d) --[[@as integer]] + + if freq == 'daily' then + return os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval })) --[[@as string]] + elseif freq == 'weekly' then + return os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval * 7 })) --[[@as string]] + elseif freq == 'monthly' then + local new_m = mn + interval + local new_y = yn + while new_m > 12 do + new_m = new_m - 12 + new_y = new_y + 1 + end + local last_day = os.date('*t', os.time({ year = new_y, month = new_m + 1, day = 0 })) --[[@as osdate]] + local clamped_d = math.min(dn, last_day.day --[[@as integer]]) + return os.date('%Y-%m-%d', os.time({ year = new_y, month = new_m, day = clamped_d })) --[[@as string]] + elseif freq == 'yearly' then + local new_y = yn + interval + local last_day = os.date('*t', os.time({ year = new_y, month = mn + 1, day = 0 })) --[[@as osdate]] + local clamped_d = math.min(dn, last_day.day --[[@as integer]]) + return os.date('%Y-%m-%d', os.time({ year = new_y, month = mn, day = clamped_d })) --[[@as string]] + end + return base_date +end + +---@param base_date string +---@param spec string +---@param mode 'scheduled'|'completion' +---@return string +function M.next_due(base_date, spec, mode) + local parsed = M.parse(spec) + if not parsed then + return base_date + end + + local today = os.date('%Y-%m-%d') --[[@as string]] + + if mode == 'completion' then + return advance_date(today, parsed.freq, parsed.interval) + end + + local next_date = advance_date(base_date, parsed.freq, parsed.interval) + while next_date <= today do + next_date = advance_date(next_date, parsed.freq, parsed.interval) + end + return next_date +end + +---@param spec string +---@return string +function M.to_rrule(spec) + local parsed = M.parse(spec) + if not parsed then + return '' + end + + if parsed._raw then + return 'RRULE:' .. parsed._raw + end + + local parts = { 'FREQ=' .. parsed.freq:upper() } + if parsed.interval > 1 then + table.insert(parts, 'INTERVAL=' .. parsed.interval) + end + if parsed.byday then + table.insert(parts, 'BYDAY=' .. table.concat(parsed.byday, ',')) + end + return 'RRULE:' .. table.concat(parts, ';') +end + +---@return string[] +function M.shorthand_list() + return { 'daily', 'weekdays', 'weekly', 'biweekly', 'monthly', 'quarterly', 'yearly', 'annual' } +end + +return M diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 5838414..4f3d2f1 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -7,6 +7,8 @@ local config = require('pending.config') ---@field category? string ---@field priority integer ---@field due? string +---@field recur? string +---@field recur_mode? 'scheduled'|'completion' ---@field entry string ---@field modified string ---@field end? string @@ -56,6 +58,8 @@ local known_fields = { category = true, priority = true, due = true, + recur = true, + recur_mode = true, entry = true, modified = true, ['end'] = true, @@ -81,6 +85,12 @@ local function task_to_table(task) if task.due then t.due = task.due end + if task.recur then + t.recur = task.recur + end + if task.recur_mode then + t.recur_mode = task.recur_mode + end if task['end'] then t['end'] = task['end'] end @@ -105,6 +115,8 @@ local function table_to_task(t) category = t.category, priority = t.priority or 0, due = t.due, + recur = t.recur, + recur_mode = t.recur_mode, entry = t.entry, modified = t.modified, ['end'] = t['end'], @@ -224,7 +236,7 @@ function M.get(id) return nil end ----@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, order?: integer, _extra?: table } +---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, recur?: string, recur_mode?: string, order?: integer, _extra?: table } ---@return pending.Task function M.add(fields) local data = M.data() @@ -236,6 +248,8 @@ function M.add(fields) category = fields.category or config.get().default_category, priority = fields.priority or 0, due = fields.due, + recur = fields.recur, + recur_mode = fields.recur_mode, entry = now, modified = now, ['end'] = nil, diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 7bcfaca..17a7a37 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -10,6 +10,7 @@ local config = require('pending.config') ---@field overdue? boolean ---@field show_category? boolean ---@field priority? integer +---@field recur? string ---@class pending.views local M = {} @@ -149,6 +150,7 @@ function M.category_view(tasks) status = task.status, category = cat, overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil, + recur = task.recur, }) end end @@ -200,6 +202,7 @@ function M.priority_view(tasks) category = task.category, overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil, show_category = true, + recur = task.recur, }) end diff --git a/plugin/pending.lua b/plugin/pending.lua index 465ee65..2f3a38f 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -22,6 +22,10 @@ vim.keymap.set('n', '(pending-open)', function() require('pending').open() end) +vim.keymap.set('n', '(pending-close)', function() + require('pending.buffer').close() +end) + vim.keymap.set('n', '(pending-toggle)', function() require('pending').toggle_complete() end) @@ -37,3 +41,15 @@ end) vim.keymap.set('n', '(pending-date)', function() require('pending').prompt_date() end) + +vim.keymap.set('n', '(pending-undo)', function() + require('pending').undo_write() +end) + +vim.keymap.set('n', '(pending-open-line)', function() + require('pending.buffer').open_line(false) +end) + +vim.keymap.set('n', '(pending-open-line-above)', function() + require('pending.buffer').open_line(true) +end) diff --git a/spec/complete_spec.lua b/spec/complete_spec.lua new file mode 100644 index 0000000..7b45e5b --- /dev/null +++ b/spec/complete_spec.lua @@ -0,0 +1,171 @@ +require('spec.helpers') + +local config = require('pending.config') +local store = require('pending.store') + +describe('complete', function() + local tmpdir + local complete = require('pending.complete') + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + config.reset() + store.unload() + store.load() + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + vim.g.pending = nil + config.reset() + end) + + describe('findstart', function() + it('returns column after colon for cat: prefix', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task cat:Wo' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 16 }) + local result = complete.omnifunc(1, '') + assert.are.equal(15, result) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('returns column after colon for due: prefix', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task due:to' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 16 }) + local result = complete.omnifunc(1, '') + assert.are.equal(15, result) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('returns column after colon for rec: prefix', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task rec:we' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 16 }) + local result = complete.omnifunc(1, '') + assert.are.equal(15, result) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('returns -1 for non-token position', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] some task ' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 14 }) + local result = complete.omnifunc(1, '') + assert.are.equal(-1, result) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + end) + + describe('completions', function() + it('returns existing categories for cat:', function() + store.add({ description = 'A', category = 'Work' }) + store.add({ description = 'B', category = 'Home' }) + store.add({ description = 'C', category = 'Work' }) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task cat: x' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 15 }) + complete.omnifunc(1, '') + local result = complete.omnifunc(0, '') + local words = {} + for _, item in ipairs(result) do + table.insert(words, item.word) + end + assert.is_true(vim.tbl_contains(words, 'Work')) + assert.is_true(vim.tbl_contains(words, 'Home')) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('filters categories by base', function() + store.add({ description = 'A', category = 'Work' }) + store.add({ description = 'B', category = 'Home' }) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task cat:W' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 15 }) + complete.omnifunc(1, '') + local result = complete.omnifunc(0, 'W') + assert.are.equal(1, #result) + assert.are.equal('Work', result[1].word) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('returns named dates for due:', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task due: x' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 15 }) + complete.omnifunc(1, '') + local result = complete.omnifunc(0, '') + assert.is_true(#result > 0) + local words = {} + for _, item in ipairs(result) do + table.insert(words, item.word) + end + assert.is_true(vim.tbl_contains(words, 'today')) + assert.is_true(vim.tbl_contains(words, 'tomorrow')) + assert.is_true(vim.tbl_contains(words, 'eom')) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('filters dates by base prefix', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task due:to' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 16 }) + complete.omnifunc(1, '') + local result = complete.omnifunc(0, 'to') + local words = {} + for _, item in ipairs(result) do + table.insert(words, item.word) + end + assert.is_true(vim.tbl_contains(words, 'today')) + assert.is_true(vim.tbl_contains(words, 'tomorrow')) + assert.is_false(vim.tbl_contains(words, 'eom')) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('returns recurrence shorthands for rec:', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task rec: x' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 15 }) + complete.omnifunc(1, '') + local result = complete.omnifunc(0, '') + assert.is_true(#result > 0) + local words = {} + for _, item in ipairs(result) do + table.insert(words, item.word) + end + assert.is_true(vim.tbl_contains(words, 'daily')) + assert.is_true(vim.tbl_contains(words, 'weekly')) + assert.is_true(vim.tbl_contains(words, '!weekly')) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('filters recurrence by base prefix', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task rec:we' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 16 }) + complete.omnifunc(1, '') + local result = complete.omnifunc(0, 'we') + local words = {} + for _, item in ipairs(result) do + table.insert(words, item.word) + end + assert.is_true(vim.tbl_contains(words, 'weekly')) + assert.is_true(vim.tbl_contains(words, 'weekdays')) + assert.is_false(vim.tbl_contains(words, 'daily')) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + end) +end) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index fda2165..d8e25c2 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -69,6 +69,25 @@ describe('diff', function() assert.are.equal('Work', result[2].category) end) + it('extracts rec: token from buffer line', function() + local lines = { + '## Inbox', + '/1/- [ ] Take trash out rec:weekly', + } + local result = diff.parse_buffer(lines) + assert.are.equal('weekly', result[2].rec) + end) + + it('extracts rec: with completion mode', function() + local lines = { + '## Inbox', + '/1/- [ ] Water plants rec:!daily', + } + local result = diff.parse_buffer(lines) + assert.are.equal('daily', result[2].rec) + assert.are.equal('completion', result[2].rec_mode) + end) + it('inline due: token is parsed', function() local lines = { '## Inbox', @@ -206,6 +225,60 @@ describe('diff', function() assert.is_nil(task.due) end) + it('stores recur field on new tasks from buffer', function() + local lines = { + '## Inbox', + '- [ ] Take out trash rec:weekly', + } + diff.apply(lines) + store.unload() + store.load() + local tasks = store.active_tasks() + assert.are.equal(1, #tasks) + assert.are.equal('weekly', tasks[1].recur) + end) + + it('updates recur field when changed inline', function() + store.add({ description = 'Task', recur = 'daily' }) + store.save() + local lines = { + '## Todo', + '/1/- [ ] Task rec:weekly', + } + diff.apply(lines) + store.unload() + store.load() + local task = store.get(1) + assert.are.equal('weekly', task.recur) + end) + + it('clears recur when token removed from line', function() + store.add({ description = 'Task', recur = 'daily' }) + store.save() + local lines = { + '## Todo', + '/1/- [ ] Task', + } + diff.apply(lines) + store.unload() + store.load() + local task = store.get(1) + assert.is_nil(task.recur) + end) + + it('parses rec: with completion mode prefix', function() + local lines = { + '## Inbox', + '- [ ] Water plants rec:!weekly', + } + diff.apply(lines) + store.unload() + store.load() + local tasks = store.active_tasks() + assert.are.equal('weekly', tasks[1].recur) + assert.are.equal('completion', tasks[1].recur_mode) + end) + it('clears priority when [N] is removed from buffer line', function() store.add({ description = 'Task name', priority = 1 }) store.save() diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index ca8047c..edeffcd 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -154,6 +154,173 @@ describe('parse', function() local result = parse.resolve_date('') assert.is_nil(result) end) + + it("returns yesterday's date for 'yesterday'", function() + local expected = os.date('%Y-%m-%d', os.time() - 86400) + local result = parse.resolve_date('yesterday') + assert.are.equal(expected, result) + end) + + it("returns today's date for 'eod'", function() + local result = parse.resolve_date('eod') + assert.are.equal(os.date('%Y-%m-%d'), result) + end) + + it('returns Monday of current week for sow', function() + local result = parse.resolve_date('sow') + assert.is_not_nil(result) + local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$') + local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) + local wday = os.date('*t', t).wday + assert.are.equal(2, wday) + end) + + it('returns Sunday of current week for eow', function() + local result = parse.resolve_date('eow') + assert.is_not_nil(result) + local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$') + local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) + local wday = os.date('*t', t).wday + assert.are.equal(1, wday) + end) + + it('returns first day of current month for som', function() + local today = os.date('*t') --[[@as osdate]] + local expected = string.format('%04d-%02d-01', today.year, today.month) + local result = parse.resolve_date('som') + assert.are.equal(expected, result) + end) + + it('returns last day of current month for eom', function() + local today = os.date('*t') --[[@as osdate]] + local expected = + os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 })) + local result = parse.resolve_date('eom') + assert.are.equal(expected, result) + end) + + it('returns first day of current quarter for soq', function() + local today = os.date('*t') --[[@as osdate]] + local q = math.ceil(today.month / 3) + local first_month = (q - 1) * 3 + 1 + local expected = string.format('%04d-%02d-01', today.year, first_month) + local result = parse.resolve_date('soq') + assert.are.equal(expected, result) + end) + + it('returns last day of current quarter for eoq', function() + local today = os.date('*t') --[[@as osdate]] + local q = math.ceil(today.month / 3) + local last_month = q * 3 + local expected = + os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 })) + local result = parse.resolve_date('eoq') + assert.are.equal(expected, result) + end) + + it('returns Jan 1 of current year for soy', function() + local today = os.date('*t') --[[@as osdate]] + local expected = string.format('%04d-01-01', today.year) + local result = parse.resolve_date('soy') + assert.are.equal(expected, result) + end) + + it('returns Dec 31 of current year for eoy', function() + local today = os.date('*t') --[[@as osdate]] + local expected = string.format('%04d-12-31', today.year) + local result = parse.resolve_date('eoy') + assert.are.equal(expected, result) + end) + + it('resolves +2w to 14 days from today', function() + local today = os.date('*t') --[[@as osdate]] + local expected = os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + 14 }) + ) + local result = parse.resolve_date('+2w') + assert.are.equal(expected, result) + end) + + it('resolves +3m to 3 months from today', function() + local today = os.date('*t') --[[@as osdate]] + local expected = os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month + 3, day = today.day }) + ) + local result = parse.resolve_date('+3m') + assert.are.equal(expected, result) + end) + + it('resolves -2d to 2 days ago', function() + local today = os.date('*t') --[[@as osdate]] + local expected = os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day - 2 }) + ) + local result = parse.resolve_date('-2d') + assert.are.equal(expected, result) + end) + + it('resolves -1w to 7 days ago', function() + local today = os.date('*t') --[[@as osdate]] + local expected = os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day - 7 }) + ) + local result = parse.resolve_date('-1w') + assert.are.equal(expected, result) + end) + + it("resolves 'later' to someday_date", function() + local result = parse.resolve_date('later') + assert.are.equal('9999-12-30', result) + end) + + it("resolves 'someday' to someday_date", function() + local result = parse.resolve_date('someday') + assert.are.equal('9999-12-30', result) + end) + + it('resolves 15th to next 15th of month', function() + local result = parse.resolve_date('15th') + assert.is_not_nil(result) + local _, _, d = result:match('^(%d+)-(%d+)-(%d+)$') + assert.are.equal('15', d) + end) + + it('resolves 1st to next 1st of month', function() + local result = parse.resolve_date('1st') + assert.is_not_nil(result) + local _, _, d = result:match('^(%d+)-(%d+)-(%d+)$') + assert.are.equal('01', d) + end) + + it('resolves jan to next January 1st', function() + local today = os.date('*t') --[[@as osdate]] + local result = parse.resolve_date('jan') + assert.is_not_nil(result) + local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$') + assert.are.equal('01', m) + assert.are.equal('01', d) + if today.month >= 1 then + assert.are.equal(tostring(today.year + 1), y) + end + end) + + it('resolves dec to next December 1st', function() + local today = os.date('*t') --[[@as osdate]] + local result = parse.resolve_date('dec') + assert.is_not_nil(result) + local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$') + assert.are.equal('12', m) + assert.are.equal('01', d) + if today.month >= 12 then + assert.are.equal(tostring(today.year + 1), y) + else + assert.are.equal(tostring(today.year), y) + end + end) end) describe('command_add', function() diff --git a/spec/recur_spec.lua b/spec/recur_spec.lua new file mode 100644 index 0000000..53b7478 --- /dev/null +++ b/spec/recur_spec.lua @@ -0,0 +1,223 @@ +require('spec.helpers') + +describe('recur', function() + local recur = require('pending.recur') + + describe('parse', function() + it('parses daily', function() + local r = recur.parse('daily') + assert.are.equal('daily', r.freq) + assert.are.equal(1, r.interval) + assert.is_false(r.from_completion) + end) + + it('parses weekdays', function() + local r = recur.parse('weekdays') + assert.are.equal('weekly', r.freq) + assert.are.same({ 'MO', 'TU', 'WE', 'TH', 'FR' }, r.byday) + end) + + it('parses weekly', function() + local r = recur.parse('weekly') + assert.are.equal('weekly', r.freq) + assert.are.equal(1, r.interval) + end) + + it('parses biweekly', function() + local r = recur.parse('biweekly') + assert.are.equal('weekly', r.freq) + assert.are.equal(2, r.interval) + end) + + it('parses monthly', function() + local r = recur.parse('monthly') + assert.are.equal('monthly', r.freq) + assert.are.equal(1, r.interval) + end) + + it('parses quarterly', function() + local r = recur.parse('quarterly') + assert.are.equal('monthly', r.freq) + assert.are.equal(3, r.interval) + end) + + it('parses yearly', function() + local r = recur.parse('yearly') + assert.are.equal('yearly', r.freq) + assert.are.equal(1, r.interval) + end) + + it('parses annual as yearly', function() + local r = recur.parse('annual') + assert.are.equal('yearly', r.freq) + end) + + it('parses 3d as every 3 days', function() + local r = recur.parse('3d') + assert.are.equal('daily', r.freq) + assert.are.equal(3, r.interval) + end) + + it('parses 2w as biweekly', function() + local r = recur.parse('2w') + assert.are.equal('weekly', r.freq) + assert.are.equal(2, r.interval) + end) + + it('parses 6m as every 6 months', function() + local r = recur.parse('6m') + assert.are.equal('monthly', r.freq) + assert.are.equal(6, r.interval) + end) + + it('parses 2y as every 2 years', function() + local r = recur.parse('2y') + assert.are.equal('yearly', r.freq) + assert.are.equal(2, r.interval) + end) + + it('parses ! prefix as completion-based', function() + local r = recur.parse('!weekly') + assert.are.equal('weekly', r.freq) + assert.is_true(r.from_completion) + end) + + it('parses raw RRULE fragment', function() + local r = recur.parse('FREQ=MONTHLY;BYDAY=1MO') + assert.is_not_nil(r) + end) + + it('returns nil for invalid input', function() + assert.is_nil(recur.parse('')) + assert.is_nil(recur.parse('garbage')) + assert.is_nil(recur.parse('0d')) + end) + + it('is case insensitive', function() + local r = recur.parse('Weekly') + assert.are.equal('weekly', r.freq) + end) + end) + + describe('validate', function() + it('returns true for valid specs', function() + assert.is_true(recur.validate('daily')) + assert.is_true(recur.validate('2w')) + assert.is_true(recur.validate('!monthly')) + end) + + it('returns false for invalid specs', function() + assert.is_false(recur.validate('garbage')) + assert.is_false(recur.validate('')) + end) + end) + + describe('next_due', function() + it('advances daily by 1 day', function() + local result = recur.next_due('2099-03-01', 'daily', 'scheduled') + assert.are.equal('2099-03-02', result) + end) + + it('advances weekly by 7 days', function() + local result = recur.next_due('2099-03-01', 'weekly', 'scheduled') + assert.are.equal('2099-03-08', result) + end) + + it('advances monthly and clamps day', function() + local result = recur.next_due('2099-01-31', 'monthly', 'scheduled') + assert.are.equal('2099-02-28', result) + end) + + it('advances yearly and handles leap year', function() + local result = recur.next_due('2096-02-29', 'yearly', 'scheduled') + assert.are.equal('2097-02-28', result) + end) + + it('advances biweekly by 14 days', function() + local result = recur.next_due('2099-03-01', 'biweekly', 'scheduled') + assert.are.equal('2099-03-15', result) + end) + + it('advances quarterly by 3 months', function() + local result = recur.next_due('2099-01-15', 'quarterly', 'scheduled') + assert.are.equal('2099-04-15', result) + end) + + it('scheduled mode skips to future if overdue', function() + local result = recur.next_due('2020-01-01', 'yearly', 'scheduled') + local today = os.date('%Y-%m-%d') --[[@as string]] + assert.is_true(result > today) + end) + + it('completion mode advances from today', function() + local today = os.date('*t') --[[@as osdate]] + local expected = os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month, + day = today.day + 7, + }) + ) + local result = recur.next_due('2020-01-01', 'weekly', 'completion') + assert.are.equal(expected, result) + end) + + it('advances 3d by 3 days', function() + local result = recur.next_due('2099-06-10', '3d', 'scheduled') + assert.are.equal('2099-06-13', result) + end) + end) + + describe('to_rrule', function() + it('converts daily', function() + assert.are.equal('RRULE:FREQ=DAILY', recur.to_rrule('daily')) + end) + + it('converts weekly', function() + assert.are.equal('RRULE:FREQ=WEEKLY', recur.to_rrule('weekly')) + end) + + it('converts biweekly with interval', function() + assert.are.equal('RRULE:FREQ=WEEKLY;INTERVAL=2', recur.to_rrule('biweekly')) + end) + + it('converts weekdays with BYDAY', function() + assert.are.equal('RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', recur.to_rrule('weekdays')) + end) + + it('converts monthly', function() + assert.are.equal('RRULE:FREQ=MONTHLY', recur.to_rrule('monthly')) + end) + + it('converts quarterly with interval', function() + assert.are.equal('RRULE:FREQ=MONTHLY;INTERVAL=3', recur.to_rrule('quarterly')) + end) + + it('converts yearly', function() + assert.are.equal('RRULE:FREQ=YEARLY', recur.to_rrule('yearly')) + end) + + it('converts 2w with interval', function() + assert.are.equal('RRULE:FREQ=WEEKLY;INTERVAL=2', recur.to_rrule('2w')) + end) + + it('prefixes raw RRULE fragment', function() + assert.are.equal('RRULE:FREQ=MONTHLY;BYDAY=1MO', recur.to_rrule('FREQ=MONTHLY;BYDAY=1MO')) + end) + + it('returns empty string for invalid spec', function() + assert.are.equal('', recur.to_rrule('garbage')) + end) + end) + + describe('shorthand_list', function() + it('returns a list of named shorthands', function() + local list = recur.shorthand_list() + assert.is_true(#list >= 8) + assert.is_true(vim.tbl_contains(list, 'daily')) + assert.is_true(vim.tbl_contains(list, 'weekly')) + assert.is_true(vim.tbl_contains(list, 'monthly')) + end) + end) +end) diff --git a/spec/store_spec.lua b/spec/store_spec.lua index bb6266d..ebe4da1 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -196,6 +196,41 @@ describe('store', function() end) end) + describe('recurrence fields', function() + it('persists recur and recur_mode through round-trip', function() + store.load() + store.add({ description = 'Recurring', recur = 'weekly', recur_mode = 'scheduled' }) + store.save() + store.unload() + store.load() + local task = store.get(1) + assert.are.equal('weekly', task.recur) + assert.are.equal('scheduled', task.recur_mode) + end) + + it('persists recur without recur_mode', function() + store.load() + store.add({ description = 'Simple recur', recur = 'daily' }) + store.save() + store.unload() + store.load() + local task = store.get(1) + assert.are.equal('daily', task.recur) + assert.is_nil(task.recur_mode) + end) + + it('omits recur fields when not set', function() + store.load() + store.add({ description = 'No recur' }) + store.save() + store.unload() + store.load() + local task = store.get(1) + assert.is_nil(task.recur) + assert.is_nil(task.recur_mode) + end) + end) + describe('active_tasks', function() it('excludes deleted tasks', function() store.load() diff --git a/spec/views_spec.lua b/spec/views_spec.lua index 4d91e06..e8d5c2d 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -204,6 +204,30 @@ describe('views', function() assert.is_falsy(task_meta.overdue) end) + it('includes recur in LineMeta for recurring tasks', function() + store.add({ description = 'Recurring', category = 'Inbox', recur = 'weekly' }) + local _, meta = views.category_view(store.active_tasks()) + local task_meta + for _, m in ipairs(meta) do + if m.type == 'task' then + task_meta = m + end + end + assert.are.equal('weekly', task_meta.recur) + end) + + it('has nil recur in LineMeta for non-recurring tasks', function() + store.add({ description = 'Normal', category = 'Inbox' }) + local _, meta = views.category_view(store.active_tasks()) + local task_meta + for _, m in ipairs(meta) do + if m.type == 'task' then + task_meta = m + end + end + assert.is_nil(task_meta.recur) + end) + it('respects category_order when set', function() vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work', 'Inbox' } } config.reset() @@ -399,5 +423,29 @@ describe('views', function() end assert.is_falsy(task_meta.overdue) end) + + it('includes recur in LineMeta for recurring tasks', function() + store.add({ description = 'Recurring', category = 'Inbox', recur = 'daily' }) + local _, meta = views.priority_view(store.active_tasks()) + local task_meta + for _, m in ipairs(meta) do + if m.type == 'task' then + task_meta = m + end + end + assert.are.equal('daily', task_meta.recur) + end) + + it('has nil recur in LineMeta for non-recurring tasks', function() + store.add({ description = 'Normal', category = 'Inbox' }) + local _, meta = views.priority_view(store.active_tasks()) + local task_meta + for _, m in ipairs(meta) do + if m.type == 'task' then + task_meta = m + end + end + assert.is_nil(task_meta.recur) + end) end) end)