pending.nvim/spec/views_spec.lua
Barrett Ruth 7d93c4bb45
feat: omnifunc completion, recurring tasks, expanded date syntax (#27)
* feat(config): add recur_syntax and someday_date fields

Problem: the plugin needs configuration for the recurrence token name
and the sentinel date used by the `later`/`someday` named dates.

Solution: add `recur_syntax` (default 'rec') and `someday_date`
(default '9999-12-30') to pending.Config and the defaults table.

* feat(parse): expand date vocabulary with named dates

Problem: the date input only supports today, tomorrow, +Nd, and
weekday names, lacking relative offsets like weeks/months, period
boundaries, ordinals, month names, and backdating.

Solution: add yesterday, eod, sow/eow, som/eom, soq/eoq, soy/eoy,
+Nw, +Nm, -Nd, -Nw, ordinals (1st-31st), month names (jan-dec),
and later/someday to resolve_date(). Add tests for all new tokens.

* feat(recur): add recurrence parsing and next-date computation

Problem: the plugin has no concept of recurring tasks, which is
needed for habits and repeating deadlines.

Solution: add recur.lua with parse(), validate(), next_due(),
to_rrule(), and shorthand_list(). Supports named shorthands (daily,
weekdays, weekly, etc.), interval notation (Nd, Nw, Nm, Ny), raw
RRULE passthrough, and ! prefix for completion-based mode. Includes
day-clamping for month/year advancement.

* feat(store): add recur and recur_mode task fields

Problem: the task schema has no fields for storing recurrence rules.

Solution: add recur and recur_mode to the Task class, known_fields,
task_to_table, table_to_task, and the add() signature.

* feat(parse): add rec: inline token parsing

Problem: the buffer parser does not recognize recurrence tokens,
so users cannot set recurrence rules inline.

Solution: add recur_key() helper and rec: token parsing in body()
and command_add(), with ! prefix handling for completion-based mode
and validation via recur.validate().

* feat(diff): propagate recurrence through buffer reconciliation

Problem: the diff layer does not extract or apply recurrence fields,
so rec: tokens written in the buffer are silently ignored on :w.

Solution: add rec and rec_mode to ParsedEntry, extract them in
parse_buffer(), and pass them through create and update paths in
apply().

* feat(init): spawn next task on recurring task completion

Problem: completing a recurring task does not create the next
occurrence, and :Pending add does not pass recurrence fields.

Solution: in toggle_complete(), detect recurrence and spawn a new
pending task with the next due date. Wire rec/rec_mode through the
add() command path.

* feat(views): add recurrence to LineMeta

Problem: LineMeta does not carry recurrence info, so the buffer
layer cannot display recurrence indicators.

Solution: add recur field to LineMeta and populate it in both
category_view() and priority_view().

* feat(buffer): add PendingRecur highlight and recurrence virtual text

Problem: recurring tasks have no visual indicator in the buffer,
and the extmark logic uses a rigid if/elseif chain that does not
compose well with additional virtual text fields.

Solution: add PendingRecur highlight group linking to DiagnosticInfo.
Refactor apply_extmarks() to build virtual text parts dynamically,
appending category, recurrence indicator, and due date as separate
composable segments. Set omnifunc on the pending buffer.

* feat(complete): add omnifunc for cat:, due:, and rec: tokens

Problem: the pending buffer has no completion source, requiring
users to type metadata tokens from memory.

Solution: add complete.lua with an omnifunc that completes cat:
tokens from existing categories, due: tokens from the named date
vocabulary, and rec: tokens from recurrence shorthands.

* docs: document recurrence, expanded dates, omnifunc, new config

Problem: the vimdoc does not cover recurrence, expanded date syntax,
omnifunc completion, or the new config fields.

Solution: add DATE INPUT and RECURRENCE sections, update INLINE
METADATA, COMMANDS, CONFIGURATION, HIGHLIGHT GROUPS, HEALTH CHECK,
and DATA FORMAT. Expand the help popup with recurrence patterns and
new date tokens. Add recurrence validation to healthcheck.

* ci: fix

* fix(recur): resolve LuaLS type errors

Problem: LuaLS reported undefined-field for `_raw` on RecurSpec and
param-type-mismatch for `last_day.day` in `advance_date` because
`osdate.day` infers as `string|integer`.

Solution: Add `_raw` to the RecurSpec class annotation and cast
`last_day.day` to integer in both `math.min` call sites.

* refactor(init): remove help popup, use config-driven keymaps

Problem: Buffer-local keymaps were hardcoded with no way for users to
customize them. The g? help popup duplicated information already in the
vimdoc.

Solution: Remove show_help() and the g? mapping. Refactor
_setup_buf_mappings to read from cfg.keymaps, letting users override or
disable any buffer-local binding via vim.g.pending.

* feat(config): add keymaps table for buffer-local bindings

Problem: Users had no way to customize or disable buffer-local key
bindings in the pending buffer.

Solution: Add a pending.Keymaps class and keymaps field to
pending.Config with defaults for all eight buffer actions. Setting any
key to false disables that binding.

* feat(plugin): add Plug mappings for all buffer actions

Problem: Only five of nine buffer actions had <Plug> mappings, so users
could not bind close, undo, open-line, or open-line-above globally.

Solution: Add <Plug>(pending-close), <Plug>(pending-undo),
<Plug>(pending-open-line), and <Plug>(pending-open-line-above).

* docs: update mappings and config for keymaps and new Plug entries

Problem: Vimdoc still listed g? help popup, lacked documentation for
the four new <Plug> mappings, and had no keymaps config section.

Solution: Remove g? from mappings table, document all nine <Plug>
mappings, add keymaps table to the config example and field reference,
and note that buffer-local keys are configurable.
2026-02-25 13:27:52 -05:00

451 lines
16 KiB
Lua

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('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()
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)
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)