From 3a35fab6cfb7bf7d39d7f91464162a15359e360a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:33:07 -0500 Subject: [PATCH] feat: overdue highlighting, relative dates, undo write, buffer mappings (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(config): add category_order field Problem: category display order was always insertion order with no way to configure it. Solution: add category_order to config defaults so users can declare a preferred category ordering; unspecified categories append after. * feat(parse): add relative date resolution Problem: due dates required full YYYY-MM-DD input, adding friction for common cases like "today" or "next monday". Solution: add resolve_date() supporting today, tomorrow, +Nd, and weekday abbreviations; extend inline token parsing to resolve relative values before falling back to strict date validation. * feat(views): overdue flag, category in priority view, category ordering Problem: overdue tasks were visually indistinct from upcoming ones; priority view had no category context; category display order was not configurable. Solution: compute overdue meta flag for pending tasks past their due date; set show_category on priority view task meta; reorder categories according to config.category_order when present. * feat(buffer): overdue highlight, category virt text in priority view Problem: overdue tasks had no visual distinction; priority view showed no category context alongside due dates. Solution: add PendingOverdue highlight group; render category name as right-aligned virtual text in priority view, composited with the due date when both are present. * feat(init): undo write and buffer-local default mappings Problem: _undo_state was captured on every save but never consumed; toggle_priority and prompt_date had no buffer-local defaults, requiring manual configuration. Solution: implement undo_write() to restore pre-save task state; add !, d, and U as buffer-local defaults following fugitive's philosophy of owning the buffer; expose :Pending undo as a command alias. * test(views): add views spec Problem: views.lua had no test coverage. Solution: add 26 tests covering category_view and priority_view including sort order, line format, overdue detection, show_category meta, and category_order config behavior. * test(archive): add archive spec Problem: archive had no test coverage. Solution: add 9 tests covering cutoff logic, custom day counts, pending task preservation, deleted task cleanup, and notify output. * docs: add vimdoc Problem: no :help documentation existed. Solution: add doc/pending.txt covering all features — commands, mappings, views, configuration, Google Calendar sync, highlight groups, data format, and health check — following standard vimdoc conventions. * ci: format * fix: resolve lint and type check errors Problem: selene flagged unused variables in new spec files; LuaLS flagged os.date/os.time return type mismatches, integer? assignments, and stale task.Task/task.GcalConfig type references. Solution: prefix unused spec variables with _ or drop unnecessary assignments; add --[[@as string/integer]] casts for os.date and os.time calls; add category_order field to pending.Config annotation; fix task.GcalConfig -> pending.GcalConfig and task.Task[] -> pending.Task[]; add nil guards on meta[row].id before store calls; cast store.data() return to non-optional. * ci: format * fix: sync * ci: format --- doc/pending.txt | 415 ++++++++++++++++++++++++++++++++++++++ lua/pending/buffer.lua | 21 +- lua/pending/config.lua | 4 +- lua/pending/diff.lua | 2 +- lua/pending/init.lua | 52 ++++- lua/pending/parse.lua | 87 +++++++- lua/pending/store.lua | 6 +- lua/pending/sync/gcal.lua | 13 +- lua/pending/views.lua | 33 ++- spec/archive_spec.lua | 131 ++++++++++++ spec/views_spec.lua | 403 ++++++++++++++++++++++++++++++++++++ 11 files changed, 1137 insertions(+), 30 deletions(-) create mode 100644 doc/pending.txt create mode 100644 spec/archive_spec.lua create mode 100644 spec/views_spec.lua diff --git a/doc/pending.txt b/doc/pending.txt new file mode 100644 index 0000000..4986467 --- /dev/null +++ b/doc/pending.txt @@ -0,0 +1,415 @@ +*pending.txt* Buffer-centric task management for Neovim + +Author: Barrett Ruth +License: MIT + +============================================================================== +INTRODUCTION *pending.nvim* + +pending.nvim is a buffer-centric task manager for Neovim. Tasks live in a +plain, editable buffer — add with `o`, delete with `dd`, reorder with +`dd`/`p`, rename by typing. Writing the buffer with `:w` computes a diff +against the JSON store and applies only the changes. No floating windows, +no special UI, no abstraction between you and your tasks. + +The buffer looks like this: > + + School + ! Read chapter 5 Feb 28 + Submit homework Feb 25 + + Errands + Buy groceries Mar 01 + Clean apartment +< + +Category headers sit at column 0. Tasks are indented two spaces below them. +`!` marks a priority task. Due dates appear as right-aligned virtual text. +Completed tasks are rendered with strikethrough. Task IDs are embedded as +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 +- Two views: category (default) and priority flat list +- Single-level undo for the last `:w` save +- Quick-add from the command line with `:Pending add` +- Google Calendar one-way push via OAuth PKCE + +============================================================================== +REQUIREMENTS *pending-requirements* + +- Neovim 0.10+ +- No external dependencies for local use +- `curl` and `openssl` are required for Google Calendar sync + +============================================================================== +INSTALL *pending-install* + +Install with lazy.nvim: >lua + { 'barrettruth/pending.nvim' } +< + +Install with luarocks: >vim + luarocks install pending.nvim +< + +No `setup()` call is needed. The plugin loads automatically and works with +defaults. To customize behavior, set |vim.g.pending| before the plugin loads. +See |pending-config|. + +============================================================================== +USAGE *pending-usage* + +Open the task buffer: >vim + :Pending +< + +The buffer named `pending://` opens in the current window. From there, use +standard Vim editing: + +- `o` / `O` to add a new task line under or above the cursor +- `dd` to remove a task (deletion is applied on `:w`) +- `dd` + `p` to reorder tasks (pasted tasks receive new IDs) +- `:w` to save — all additions, deletions, and edits are diffed against the + store and committed atomically + +Buffer-local keys are set automatically when the buffer opens. See +|pending-mappings| for the full list. + +The buffer uses `buftype=acwrite` so `:w` always routes through pending.nvim's +write handler rather than writing to disk directly. The `pending://` buffer +persists across window switches; reopening with `:Pending` focuses the +existing window if one is open. + +============================================================================== +INLINE METADATA *pending-metadata* + +Metadata tokens may be appended to any task line before saving. Tokens are +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` + `cat:Name` Move the task to the named category on save. + +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. + +Example: > + + Buy milk due:2026-03-15 cat:Errands +< + +On `:w`, the description becomes `Buy milk`, the due date is stored as +`2026-03-15` and rendered as right-aligned virtual text, and the task is +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. + +============================================================================== +COMMANDS *pending-commands* + + *:Pending* +:Pending + Open the task buffer. If the buffer is already displayed in a window, + focus that window. Equivalent to |(pending-open)|. + + *:Pending-add* +:Pending add {text} + Quick-add a task without opening the buffer. Inline metadata tokens in + {text} are parsed exactly as they are in the buffer. A `Category: ` prefix + (uppercase first letter, colon, space) assigns the category directly: >vim + :Pending add Buy groceries due:2026-03-15 + :Pending add School: Submit homework + :Pending add Errands: Pick up dry cleaning due:fri +< + If the buffer is currently open it is re-rendered after the add. + + *:Pending-archive* +:Pending archive [{days}] + Permanently remove done and deleted tasks whose completion timestamp is + older than {days} days. {days} defaults to 30 if not provided. >vim + :Pending archive " remove tasks completed more than 30 days ago + :Pending archive 7 " remove tasks completed more than 7 days ago +< + + *:Pending-sync* +:Pending sync + Push pending tasks that have a due date to Google Calendar as all-day + events. Requires |pending-gcal| to be configured. See |pending-gcal| for + full details on what gets created, updated, and deleted. + + *:Pending-undo* +:Pending undo + There is no `:Pending undo` subcommand. Use the `U` buffer-local key + (see |pending-mappings|) to undo the last `:w` save while the task buffer + is open. + +============================================================================== +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: ~ + + 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 + +Standard Vim keys `o`, `O`, `dd`, `p`, `P`, and `:w` work as expected. + + *(pending-open)* +(pending-open) + Open the task buffer. Maps to |:Pending| with no arguments. + + *(pending-toggle)* +(pending-toggle) + Toggle complete / uncomplete for the task under the cursor. + + *(pending-priority)* +(pending-priority) + Toggle the priority flag for the task under the cursor. + + *(pending-date)* +(pending-date) + Prompt for a due date for the task under the cursor. + + *(pending-view)* +(pending-view) + Switch between category view and priority view. + +Example configuration: >lua + vim.keymap.set('n', 't', '(pending-open)') + vim.keymap.set('n', 'T', '(pending-toggle)') +< + +============================================================================== +VIEWS *pending-views* + +Two views are available. Switch with `` or |(pending-view)|. + +Category view (default): ~ *pending-view-category* + Tasks are grouped under their category header. Categories appear in the + order tasks were added unless `category_order` is set (see + |pending-config|). Blank lines separate categories. Within each category, + pending tasks appear before done tasks. Priority tasks (`!`) are sorted + first within each group. + +Priority view: ~ *pending-view-priority* + A flat list of all tasks sorted by priority, then by due date (tasks + without a due date sort last), then by internal order. Done tasks appear + after all pending tasks. Category names are shown as right-aligned virtual + text alongside the due date virtual text so tasks remain identifiable + across categories. + +============================================================================== +CONFIGURATION *pending-config* + +Configuration is done via `vim.g.pending`. Set this before the plugin +loads: >lua + vim.g.pending = { + data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', + default_view = 'category', + default_category = 'Inbox', + date_format = '%b %d', + date_syntax = 'due', + category_order = {}, + gcal = { + calendar = 'Tasks', + credentials_path = '/path/to/client_secret.json', + }, + } +< + +All fields are optional. Unset fields use the defaults shown above. + + *pending.Config* +Fields: ~ + {data_path} (string) + Path to the JSON file where tasks are stored. + Default: `stdpath('data') .. '/pending/tasks.json'`. + The directory is created automatically on first save. + + {default_view} ('category'|'priority', default: 'category') + The view to use when the buffer is opened for the + first time in a session. + + {default_category} (string, default: 'Inbox') + Category assigned to new tasks when no `cat:` token + is present and no `Category: ` prefix is used with + `:Pending add`. + + {date_format} (string, default: '%b %d') + strftime format string used to render due dates as + virtual text in the buffer. Examples: `'%Y-%m-%d'` + for ISO dates, `'%d %b'` for day-first. + + {date_syntax} (string, default: 'due') + The token name for inline due-date metadata. Change + this to use a different keyword, for example `'by'` + to write `by:2026-03-15` instead of `due:2026-03-15`. + + {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. + + {gcal} (table, default: nil) + Google Calendar sync configuration. See + |pending.GcalConfig|. Omit this field entirely to + disable Google Calendar sync. + +============================================================================== +GOOGLE CALENDAR *pending-gcal* + +pending.nvim can push tasks with due dates to a dedicated Google Calendar as +all-day events. This is a one-way push; changes made in Google Calendar are +not pulled back into pending.nvim. + +Configuration: >lua + vim.g.pending = { + gcal = { + calendar = 'Tasks', + credentials_path = '/path/to/client_secret.json', + }, + } +< + + *pending.GcalConfig* +Fields: ~ + {calendar} (string, default: 'Pendings') + Name of the Google Calendar to sync to. If a calendar + with this name does not exist it is created + automatically on the first sync. + + {credentials_path} (string) + Path to the OAuth client secret JSON file downloaded + from the Google Cloud Console. Default: + `stdpath('data')..'/pending/gcal_credentials.json'`. + The file may be in the `installed` wrapper format + that Google provides or as a bare credentials object. + +OAuth flow: ~ +On the first `:Pending sync` call the plugin detects that no refresh token +exists and opens the Google authorization URL in the browser using +|vim.ui.open()|. A temporary local HTTP server listens on port 18392 for the +OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used — +`openssl` generates the code challenge. After the user grants consent, the +authorization code is exchanged for tokens and the refresh token is stored at +`stdpath('data')/pending/gcal_tokens.json` with mode `600`. Subsequent syncs +use the stored refresh token and refresh the access token automatically when +it is about to expire. + +`:Pending sync` behavior: ~ +For each task in the store: +- A pending task with a due date and no existing event: a new all-day event is + created and the event ID is stored in the task's `_extra` table. +- A pending task with a due date and an existing event: the event summary and + date are updated in place. +- A done or deleted task with an existing event: the event is deleted. +- A pending task with no due date that had an existing event: the event is + deleted. + +A summary notification is shown after sync: `created: N, updated: N, +deleted: N`. + +============================================================================== +HIGHLIGHT GROUPS *pending-highlights* + +pending.nvim defines the following highlight groups. All groups are set with +`default`, so colorschemes can override them by defining the group without +`default` before or after the plugin loads. + + *PendingHeader* +PendingHeader Applied to category header lines (text at column 0). + Default: bold. + + *PendingDue* +PendingDue Applied to the due date virtual text shown at the right + margin of each task line. + Default: fg=#888888, italic. + + *PendingDone* +PendingDone Applied to the text of completed tasks. + Default: strikethrough, fg=#666666. + + *PendingPriority* +PendingPriority Applied to the `! ` priority marker on priority tasks. + Default: fg=#e06c75, bold. + +To override a group in your colorscheme or config: >lua + vim.api.nvim_set_hl(0, 'PendingDue', { fg = '#aaaaaa', italic = true }) +< + +============================================================================== +HEALTH CHECK *pending-health* + +Run |:checkhealth| pending to verify your setup: >vim + :checkhealth pending +< + +Checks performed: ~ +- Config loads without error +- Reports active configuration values (data path, default view, default + 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 +- Whether `curl` is available (required for Google Calendar sync) +- Whether `openssl` is available (required for OAuth PKCE) + +============================================================================== +DATA FORMAT *pending-data* + +Tasks are stored as JSON at `data_path`. The file is safe to edit by hand and +is forward-compatible — unknown fields are preserved on every read/write cycle +via the `_extra` table. + +Schema: > + + { + "version": 1, + "next_id": 42, + "tasks": [ ... ] + } +< + +Task fields: ~ + {id} (integer) Unique, auto-incrementing task identifier. + {description} (string) Task text as shown in the buffer. + {status} (string) `'pending'`, `'done'`, or `'deleted'`. + {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. + {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. + {order} (integer) Relative ordering within a category. + +Any field not in the list above is preserved in `_extra` and written back on +save. This is used internally to store the Google Calendar event ID +(`_gcal_event_id`) and allows third-party tooling to annotate tasks without +data loss. + +The `version` field is checked on load. If the file version is newer than the +version the plugin supports, loading is aborted with an error message asking +you to update the plugin. + +============================================================================== + vim:tw=78:ts=8:ft=help:norl: diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index e67b64b..d7f6b4c 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -88,9 +88,25 @@ local function apply_extmarks(bufnr, line_meta) for i, m in ipairs(line_meta) do local row = i - 1 if m.type == 'task' then - if m.due 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 } } + end + if virt_text then + vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { + virt_text = virt_text, + virt_text_pos = 'right_align', + }) + end + elseif m.due then vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { - virt_text = { { m.due, 'PendingDue' } }, + virt_text = { { m.due, due_hl } }, virt_text_pos = 'right_align', }) end @@ -120,6 +136,7 @@ local function setup_highlights() end hl('PendingHeader', { bold = true }) hl('PendingDue', { fg = '#888888', italic = true }) + hl('PendingOverdue', { fg = '#e06c75', italic = true }) hl('PendingDone', { strikethrough = true, fg = '#666666' }) hl('PendingPriority', { fg = '#e06c75', bold = true }) end diff --git a/lua/pending/config.lua b/lua/pending/config.lua index d486690..d137acb 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -8,7 +8,8 @@ ---@field default_category string ---@field date_format string ---@field date_syntax string ----@field gcal? task.GcalConfig +---@field category_order? string[] +---@field gcal? pending.GcalConfig ---@class pending.config local M = {} @@ -20,6 +21,7 @@ local defaults = { default_category = 'Inbox', date_format = '%b %d', date_syntax = 'due', + category_order = {}, } ---@type pending.Config? diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 6d6c648..9f5e577 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -16,7 +16,7 @@ local M = {} ---@return string local function timestamp() - return os.date('!%Y-%m-%dT%H:%M:%SZ') + return os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] end ---@param lines string[] diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 3c2d59b..cb930d0 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -41,6 +41,15 @@ function M._setup_buf_mappings(bufnr) 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) end ---@param bufnr integer @@ -51,6 +60,17 @@ function M._on_write(bufnr) buffer.render(bufnr) end +function M.undo_write() + if not _undo_state then + vim.notify('Nothing to undo.', vim.log.levels.WARN) + return + end + store.replace_tasks(_undo_state) + store.save() + _undo_state = nil + buffer.render(buffer.bufnr()) +end + function M.toggle_complete() local bufnr = buffer.bufnr() if not bufnr then @@ -62,6 +82,9 @@ function M.toggle_complete() return end local id = meta[row].id + if not id then + return + end local task = store.get(id) if not task then return @@ -86,6 +109,9 @@ function M.toggle_priority() return end local id = meta[row].id + if not id then + return + end local task = store.get(id) if not task then return @@ -107,13 +133,19 @@ function M.prompt_date() return end local id = meta[row].id + if not id then + return + end vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input) if not input then return end local due = input ~= '' and input or nil if due then - if not due:match('^%d%d%d%d%-%d%d%-%d%d$') then + local resolved = parse.resolve_date(due) + if resolved then + due = resolved + elseif not due:match('^%d%d%d%d%-%d%d%-%d%d$') then vim.notify('Invalid date format. Use YYYY-MM-DD.', vim.log.levels.ERROR) return end @@ -170,12 +202,12 @@ function M.archive(days) local y, mo, d, h, mi, s = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$') if y then local t = os.time({ - year = tonumber(y), - month = tonumber(mo), - day = tonumber(d), - hour = tonumber(h), - min = tonumber(mi), - sec = tonumber(s), + year = tonumber(y) --[[@as integer]], + month = tonumber(mo) --[[@as integer]], + day = tonumber(d) --[[@as integer]], + hour = tonumber(h) --[[@as integer]], + min = tonumber(mi) --[[@as integer]], + sec = tonumber(s) --[[@as integer]], }) if t < cutoff then archived = archived + 1 @@ -203,6 +235,9 @@ function M.show_help() '', ' Toggle complete/uncomplete', ' Switch category/priority view', + '! Toggle priority', + 'd Set due date', + 'U Undo last write', 'o / O Add new task line', 'dd Delete task (on :w)', 'p / P Paste (duplicates get new IDs)', @@ -212,6 +247,7 @@ function M.show_help() ':Pending add Cat: Quick-add with category', ':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', @@ -256,6 +292,8 @@ function M.command(args) elseif cmd == 'archive' then local d = rest ~= '' and tonumber(rest) or nil M.archive(d) + elseif cmd == 'undo' then + M.undo_write() else vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR) end diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 722ba50..dfe9206 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -10,7 +10,10 @@ local function is_valid_date(s) if not y then return false end - y, m, d = tonumber(y), tonumber(m), tonumber(d) + y, m, d = + tonumber(y), --[[@as integer]] + tonumber(m), --[[@as integer]] + tonumber(d) --[[@as integer]] if m < 1 or m > 12 then return false end @@ -27,6 +30,60 @@ local function date_key() return config.get().date_syntax or 'due' end +local weekday_map = { + sun = 1, + mon = 2, + tue = 3, + wed = 4, + thu = 5, + fri = 6, + sat = 7, +} + +---@param text string +---@return string|nil +function M.resolve_date(text) + local lower = text:lower() + local today = os.date('*t') + + if lower == 'today' then + return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]] + end + + if lower == 'tomorrow' then + return os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + 1 }) + ) --[[@as string]] + end + + local 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 + + local target_wday = weekday_map[lower] + if target_wday then + local current_wday = today.wday + local delta = (target_wday - current_wday) % 7 + return os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + delta }) + ) --[[@as string]] + end + + return nil +end + ---@param text string ---@return string description ---@return { due?: string, cat?: string } metadata @@ -39,11 +96,12 @@ function M.body(text) local metadata = {} local i = #tokens local dk = date_key() - local date_pattern = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$' + local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$' + local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$' while i >= 1 do local token = tokens[i] - local due_val = token:match(date_pattern) + local due_val = token:match(date_pattern_strict) if due_val then if metadata.due then break @@ -54,15 +112,28 @@ function M.body(text) metadata.due = due_val i = i - 1 else - local cat_val = token:match('^cat:(%S+)$') - if cat_val then - if metadata.cat then + local raw_val = token:match(date_pattern_any) + if raw_val then + if metadata.due then break end - metadata.cat = cat_val + local resolved = M.resolve_date(raw_val) + if not resolved then + break + end + metadata.due = resolved i = i - 1 else - break + local cat_val = token:match('^cat:(%S+)$') + if cat_val then + if metadata.cat then + break + end + metadata.cat = cat_val + i = i - 1 + else + break + end end end end diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 1ad3ad3..fae9e27 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -16,7 +16,7 @@ local config = require('pending.config') ---@class pending.Data ---@field version integer ---@field next_id integer ----@field tasks task.Task[] +---@field tasks pending.Task[] ---@class pending.store local M = {} @@ -45,7 +45,7 @@ end ---@return string local function timestamp() - return os.date('!%Y-%m-%dT%H:%M:%SZ') + return os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] end ---@type table @@ -188,7 +188,7 @@ function M.data() if not _data then M.load() end - return _data + return _data --[[@as pending.Data]] end ---@return pending.Task[] diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 30571cd..a5f57f3 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -313,7 +313,7 @@ local function find_or_create_calendar(access_token) return nil, err end - for _, item in ipairs(data.items or {}) do + for _, item in ipairs(data and data.items or {}) do if item.summary == cal_name then return item.id, nil end @@ -326,12 +326,13 @@ local function find_or_create_calendar(access_token) return nil, create_err end - return created.id, nil + return created and created.id, nil end local function next_day(date_str) local y, m, d = date_str:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') - local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) + 86400 + local t = os.time({ year = tonumber(y) or 0, month = tonumber(m) or 0, day = tonumber(d) or 0 }) + + 86400 return os.date('%Y-%m-%d', t) end @@ -354,7 +355,7 @@ local function create_event(access_token, calendar_id, task) if err then return nil, err end - return data.id, nil + return data and data.id, nil end local function update_event(access_token, calendar_id, event_id, task) @@ -416,7 +417,7 @@ function M.sync() else task._extra = extra end - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') + task.modified = tostring(os.date('!%Y-%m-%dT%H:%M:%SZ')) deleted = deleted + 1 end elseif task.status == 'pending' and task.due then @@ -432,7 +433,7 @@ function M.sync() task._extra = {} end task._extra._gcal_event_id = new_id - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') + task.modified = tostring(os.date('!%Y-%m-%dT%H:%M:%SZ')) created = created + 1 end end diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 6e6fc4f..1e599f5 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -7,6 +7,8 @@ local config = require('pending.config') ---@field raw_due? string ---@field status? string ---@field category? string +---@field overdue? boolean +---@field show_category? boolean ---@class pending.views local M = {} @@ -21,8 +23,12 @@ local function format_due(due) if not y then return due end - local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) - return os.date(config.get().date_format, t) + local t = os.time({ + year = tonumber(y) --[[@as integer]], + month = tonumber(m) --[[@as integer]], + day = tonumber(d) --[[@as integer]], + }) + return os.date(config.get().date_format, t) --[[@as string]] end ---@param tasks pending.Task[] @@ -66,6 +72,7 @@ end ---@return string[] lines ---@return pending.LineMeta[] meta function M.category_view(tasks) + local today = os.date('%Y-%m-%d') --[[@as string]] local by_cat = {} local cat_order = {} local cat_seen = {} @@ -86,6 +93,24 @@ function M.category_view(tasks) end end + local cfg_order = config.get().category_order + if cfg_order and #cfg_order > 0 then + local ordered = {} + local seen = {} + for _, name in ipairs(cfg_order) do + if cat_seen[name] then + table.insert(ordered, name) + seen[name] = true + end + end + for _, name in ipairs(cat_order) do + if not seen[name] then + table.insert(ordered, name) + end + end + cat_order = ordered + end + for _, cat in ipairs(cat_order) do sort_tasks(by_cat[cat]) sort_tasks(done_by_cat[cat]) @@ -123,6 +148,7 @@ function M.category_view(tasks) raw_due = task.due, status = task.status, category = cat, + overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil, }) end end @@ -134,6 +160,7 @@ end ---@return string[] lines ---@return pending.LineMeta[] meta function M.priority_view(tasks) + local today = os.date('%Y-%m-%d') --[[@as string]] local pending = {} local done = {} @@ -172,6 +199,8 @@ function M.priority_view(tasks) raw_due = task.due, status = task.status, category = task.category, + overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil, + show_category = true, }) end diff --git a/spec/archive_spec.lua b/spec/archive_spec.lua new file mode 100644 index 0000000..a71eee8 --- /dev/null +++ b/spec/archive_spec.lua @@ -0,0 +1,131 @@ +require('spec.helpers') + +local config = require('pending.config') +local store = require('pending.store') + +describe('archive', function() + local tmpdir + local pending = require('pending') + + 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) + + it('removes done tasks completed more than 30 days ago', function() + local t = store.add({ description = 'Old done task' }) + store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + pending.archive() + assert.are.equal(0, #store.active_tasks()) + end) + + it('keeps done tasks completed fewer than 30 days ago', function() + local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) + local t = store.add({ description = 'Recent done task' }) + store.update(t.id, { status = 'done', ['end'] = recent_end }) + pending.archive() + local active = store.active_tasks() + assert.are.equal(1, #active) + assert.are.equal('Recent done task', active[1].description) + end) + + it('respects a custom day count', function() + local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400)) + local t = store.add({ description = 'Old for 7 days' }) + store.update(t.id, { status = 'done', ['end'] = eight_days_ago }) + pending.archive(7) + assert.are.equal(0, #store.active_tasks()) + end) + + it('keeps tasks within the custom day cutoff', function() + local five_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) + local t = store.add({ description = 'Recent for 7 days' }) + store.update(t.id, { status = 'done', ['end'] = five_days_ago }) + pending.archive(7) + local active = store.active_tasks() + assert.are.equal(1, #active) + end) + + it('never archives pending tasks regardless of age', function() + store.add({ description = 'Still pending' }) + pending.archive() + local active = store.active_tasks() + assert.are.equal(1, #active) + assert.are.equal('pending', active[1].status) + end) + + it('removes deleted tasks past the cutoff', function() + local t = store.add({ description = 'Old deleted task' }) + store.update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' }) + pending.archive() + local all = store.tasks() + assert.are.equal(0, #all) + end) + + it('keeps deleted tasks within the cutoff', function() + local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) + local t = store.add({ description = 'Recent deleted' }) + store.update(t.id, { status = 'deleted', ['end'] = recent_end }) + pending.archive() + local all = store.tasks() + assert.are.equal(1, #all) + end) + + it('reports the correct count in vim.notify', function() + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, ...) + table.insert(messages, msg) + return orig_notify(msg, ...) + end + + local t1 = store.add({ description = 'Old 1' }) + local t2 = store.add({ description = 'Old 2' }) + store.add({ description = 'Keep' }) + store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + store.update(t2.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + + pending.archive() + + vim.notify = orig_notify + + local found = false + for _, msg in ipairs(messages) do + if msg:find('Archived 2') then + found = true + break + end + end + assert.is_true(found) + end) + + it('leaves only kept tasks in store.active_tasks after archive', function() + local t1 = store.add({ description = 'Old done' }) + store.add({ description = 'Keep pending' }) + local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) + local t3 = store.add({ description = 'Keep recent done' }) + store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + store.update(t3.id, { status = 'done', ['end'] = recent_end }) + + pending.archive() + + local active = store.active_tasks() + assert.are.equal(2, #active) + local descs = {} + for _, task in ipairs(active) do + descs[task.description] = true + end + assert.is_true(descs['Keep pending']) + assert.is_true(descs['Keep recent done']) + end) +end) diff --git a/spec/views_spec.lua b/spec/views_spec.lua new file mode 100644 index 0000000..9ba12f9 --- /dev/null +++ b/spec/views_spec.lua @@ -0,0 +1,403 @@ +require('spec.helpers') + +local config = require('pending.config') +local store = require('pending.store') + +describe('views', function() + local tmpdir + local views = require('pending.views') + + 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('category_view', function() + it('groups tasks under their category header', function() + store.add({ description = 'Task A', category = 'Work' }) + store.add({ description = 'Task B', category = 'Work' }) + local lines, meta = views.category_view(store.active_tasks()) + assert.are.equal('Work', lines[1]) + assert.are.equal('header', meta[1].type) + assert.is_true(lines[2]:find('Task A') ~= nil) + assert.is_true(lines[3]:find('Task B') ~= nil) + end) + + it('places pending tasks before done tasks within a category', function() + local t1 = store.add({ description = 'Done task', category = 'Work' }) + store.add({ description = 'Pending task', category = 'Work' }) + store.update(t1.id, { status = 'done' }) + local _, meta = views.category_view(store.active_tasks()) + local pending_row, done_row + for i, m in ipairs(meta) do + if m.type == 'task' and m.status == 'pending' then + pending_row = i + elseif m.type == 'task' and m.status == 'done' then + done_row = i + end + end + assert.is_true(pending_row < done_row) + end) + + it('sorts high-priority tasks before normal tasks within pending group', function() + store.add({ description = 'Normal', category = 'Work', priority = 0 }) + store.add({ description = 'High', category = 'Work', priority = 1 }) + local lines, meta = views.category_view(store.active_tasks()) + local high_row, normal_row + for i, m in ipairs(meta) do + if m.type == 'task' then + local line = lines[i] + if line:find('High') then + high_row = i + elseif line:find('Normal') then + normal_row = i + end + end + end + assert.is_true(high_row < normal_row) + end) + + it('sorts high-priority tasks before normal tasks within done group', function() + local t1 = store.add({ description = 'Done Normal', category = 'Work', priority = 0 }) + local t2 = store.add({ description = 'Done High', category = 'Work', priority = 1 }) + store.update(t1.id, { status = 'done' }) + store.update(t2.id, { status = 'done' }) + local lines, meta = views.category_view(store.active_tasks()) + local high_row, normal_row + for i, m in ipairs(meta) do + if m.type == 'task' then + local line = lines[i] + if line:find('Done High') then + high_row = i + elseif line:find('Done Normal') then + normal_row = i + end + end + end + assert.is_true(high_row < normal_row) + end) + + it('gives each category its own header with blank lines between them', function() + store.add({ description = 'Task A', category = 'Work' }) + store.add({ description = 'Task B', category = 'Personal' }) + local lines, meta = views.category_view(store.active_tasks()) + local headers = {} + local blank_found = false + for i, m in ipairs(meta) do + if m.type == 'header' then + table.insert(headers, lines[i]) + elseif m.type == 'blank' then + blank_found = true + end + end + assert.are.equal(2, #headers) + assert.is_true(blank_found) + end) + + it('formats task lines as /ID/ description', function() + store.add({ description = 'My task', category = 'Inbox' }) + local lines, meta = views.category_view(store.active_tasks()) + local task_line + for i, m in ipairs(meta) do + if m.type == 'task' then + task_line = lines[i] + end + end + assert.are.equal('/1/ My task', task_line) + end) + + it('formats priority task lines as /ID/ ! description', function() + store.add({ description = 'Important', category = 'Inbox', priority = 1 }) + local lines, meta = views.category_view(store.active_tasks()) + local task_line + for i, m in ipairs(meta) do + if m.type == 'task' then + task_line = lines[i] + end + end + assert.are.equal('/1/ ! Important', task_line) + end) + + it('sets LineMeta type=header for header lines with correct category', function() + store.add({ description = 'T', category = 'School' }) + local _, meta = views.category_view(store.active_tasks()) + assert.are.equal('header', meta[1].type) + assert.are.equal('School', meta[1].category) + end) + + it('sets LineMeta type=task with correct id and status', function() + local t = store.add({ description = 'Do something', 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.are.equal('task', task_meta.type) + assert.are.equal(t.id, task_meta.id) + assert.are.equal('pending', task_meta.status) + end) + + it('sets LineMeta type=blank for blank separator lines', function() + store.add({ description = 'A', category = 'Work' }) + store.add({ description = 'B', category = 'Home' }) + local _, meta = views.category_view(store.active_tasks()) + local blank_meta + for _, m in ipairs(meta) do + if m.type == 'blank' then + blank_meta = m + break + end + end + assert.is_not_nil(blank_meta) + assert.are.equal('blank', blank_meta.type) + end) + + it('marks overdue pending tasks with meta.overdue=true', function() + local yesterday = os.date('%Y-%m-%d', os.time() - 86400) + local t = store.add({ description = 'Overdue task', category = 'Inbox', due = yesterday }) + local _, meta = views.category_view(store.active_tasks()) + local task_meta + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + end + end + assert.is_true(task_meta.overdue == true) + end) + + it('does not mark future pending tasks as overdue', function() + local tomorrow = os.date('%Y-%m-%d', os.time() + 86400) + local t = store.add({ description = 'Future task', category = 'Inbox', due = tomorrow }) + local _, meta = views.category_view(store.active_tasks()) + local task_meta + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + end + end + assert.is_falsy(task_meta.overdue) + end) + + it('does not mark done tasks with overdue due dates as overdue', function() + local yesterday = os.date('%Y-%m-%d', os.time() - 86400) + local t = store.add({ description = 'Done late', category = 'Inbox', due = yesterday }) + store.update(t.id, { status = 'done' }) + local _, meta = views.category_view(store.active_tasks()) + local task_meta + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + end + end + assert.is_falsy(task_meta.overdue) + end) + + it('respects category_order when set', function() + vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work', 'Inbox' } } + config.reset() + store.add({ description = 'Inbox task', category = 'Inbox' }) + store.add({ description = 'Work task', category = 'Work' }) + local lines, meta = views.category_view(store.active_tasks()) + local first_header, second_header + for i, m in ipairs(meta) do + if m.type == 'header' then + if not first_header then + first_header = lines[i] + else + second_header = lines[i] + end + end + end + assert.are.equal('Work', first_header) + assert.are.equal('Inbox', second_header) + end) + + it('appends categories not in category_order after ordered ones', function() + vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work' } } + config.reset() + store.add({ description = 'Errand', category = 'Errands' }) + store.add({ description = 'Work task', category = 'Work' }) + local lines, meta = views.category_view(store.active_tasks()) + local headers = {} + for i, m in ipairs(meta) do + if m.type == 'header' then + table.insert(headers, lines[i]) + end + end + assert.are.equal('Work', headers[1]) + assert.are.equal('Errands', headers[2]) + end) + + it('preserves insertion order when category_order is empty', function() + store.add({ description = 'Alpha task', category = 'Alpha' }) + store.add({ description = 'Beta task', category = 'Beta' }) + local lines, meta = views.category_view(store.active_tasks()) + local headers = {} + for i, m in ipairs(meta) do + if m.type == 'header' then + table.insert(headers, lines[i]) + end + end + assert.are.equal('Alpha', headers[1]) + assert.are.equal('Beta', headers[2]) + end) + end) + + describe('priority_view', function() + it('places all pending tasks before done tasks', function() + local t1 = store.add({ description = 'Done A', category = 'Work' }) + store.add({ description = 'Pending B', category = 'Work' }) + store.update(t1.id, { status = 'done' }) + local _, meta = views.priority_view(store.active_tasks()) + local last_pending_row, first_done_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if m.status == 'pending' then + last_pending_row = i + elseif m.status == 'done' and not first_done_row then + first_done_row = i + end + end + end + assert.is_true(last_pending_row < first_done_row) + end) + + it('sorts pending tasks by priority desc within pending group', function() + store.add({ description = 'Low', category = 'Work', priority = 0 }) + store.add({ description = 'High', category = 'Work', priority = 1 }) + local lines, meta = views.priority_view(store.active_tasks()) + local high_row, low_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('High') then + high_row = i + elseif lines[i]:find('Low') then + low_row = i + end + end + end + assert.is_true(high_row < low_row) + end) + + it('sorts pending tasks with due dates before those without', function() + store.add({ description = 'No due', category = 'Work' }) + store.add({ description = 'Has due', category = 'Work', due = '2099-12-31' }) + local lines, meta = views.priority_view(store.active_tasks()) + local due_row, nodue_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('Has due') then + due_row = i + elseif lines[i]:find('No due') then + nodue_row = i + end + end + end + assert.is_true(due_row < nodue_row) + end) + + it('sorts pending tasks with earlier due dates before later due dates', function() + store.add({ description = 'Later', category = 'Work', due = '2099-12-31' }) + store.add({ description = 'Earlier', category = 'Work', due = '2050-01-01' }) + local lines, meta = views.priority_view(store.active_tasks()) + local earlier_row, later_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('Earlier') then + earlier_row = i + elseif lines[i]:find('Later') then + later_row = i + end + end + end + assert.is_true(earlier_row < later_row) + end) + + it('formats task lines as /ID/ description', function() + store.add({ description = 'My task', category = 'Inbox' }) + local lines, _ = views.priority_view(store.active_tasks()) + assert.are.equal('/1/ My task', lines[1]) + end) + + it('sets show_category=true for all task meta entries', function() + store.add({ description = 'T1', category = 'Work' }) + store.add({ description = 'T2', category = 'Personal' }) + local _, meta = views.priority_view(store.active_tasks()) + for _, m in ipairs(meta) do + if m.type == 'task' then + assert.is_true(m.show_category == true) + end + end + end) + + it('sets meta.category correctly for each task', function() + store.add({ description = 'Work task', category = 'Work' }) + store.add({ description = 'Home task', category = 'Home' }) + local lines, meta = views.priority_view(store.active_tasks()) + local categories = {} + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('Work task') then + categories['Work task'] = m.category + elseif lines[i]:find('Home task') then + categories['Home task'] = m.category + end + end + end + assert.are.equal('Work', categories['Work task']) + assert.are.equal('Home', categories['Home task']) + end) + + it('marks overdue pending tasks with meta.overdue=true', function() + local yesterday = os.date('%Y-%m-%d', os.time() - 86400) + local t = store.add({ description = 'Overdue', category = 'Inbox', due = yesterday }) + local _, meta = views.priority_view(store.active_tasks()) + local task_meta + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + end + end + assert.is_true(task_meta.overdue == true) + end) + + it('does not mark future pending tasks as overdue', function() + local tomorrow = os.date('%Y-%m-%d', os.time() + 86400) + local t = store.add({ description = 'Future', category = 'Inbox', due = tomorrow }) + local _, meta = views.priority_view(store.active_tasks()) + local task_meta + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + end + end + assert.is_falsy(task_meta.overdue) + end) + + it('does not mark done tasks with overdue due dates as overdue', function() + local yesterday = os.date('%Y-%m-%d', os.time() - 86400) + local t = store.add({ description = 'Done late', category = 'Inbox', due = yesterday }) + store.update(t.id, { status = 'done' }) + local _, meta = views.priority_view(store.active_tasks()) + local task_meta + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + end + end + assert.is_falsy(task_meta.overdue) + end) + end) +end)