feat: overdue highlighting, relative dates, undo write, buffer mappings (#1)

* 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 <Plug> 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
This commit is contained in:
Barrett Ruth 2026-02-24 18:33:07 -05:00
parent 7b97e9b840
commit 3a35fab6cf
11 changed files with 1137 additions and 30 deletions

View file

@ -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()
'',
'<CR> Toggle complete/uncomplete',
'<Tab> 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: <text> 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