feat(archive): duration syntax and confirmation prompt

Problem: `:Pending archive` accepted only a bare integer for days and
silently deleted tasks with no confirmation, risking accidental data loss.

Solution: Accept duration strings (`7d`, `3w`, `2m`) via
`parse.parse_duration_to_days()`, show a `vim.ui.input` confirmation
prompt before removing tasks, and skip the prompt when zero tasks match.
This commit is contained in:
Barrett Ruth 2026-03-08 20:25:33 -04:00
parent 8cfdafe464
commit cfcaaca28b
4 changed files with 220 additions and 25 deletions

View file

@ -5,6 +5,7 @@ local config = require('pending.config')
describe('archive', function()
local tmpdir
local pending
local ui_input_orig
before_each(function()
tmpdir = vim.fn.tempname()
@ -14,16 +15,31 @@ describe('archive', function()
package.loaded['pending'] = nil
pending = require('pending')
pending.store():load()
ui_input_orig = vim.ui.input
end)
after_each(function()
vim.ui.input = ui_input_orig
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
package.loaded['pending'] = nil
end)
local function auto_confirm_y()
vim.ui.input = function(opts, on_confirm)
on_confirm('y')
end
end
local function auto_confirm_n()
vim.ui.input = function(opts, on_confirm)
on_confirm('n')
end
end
it('removes done tasks completed more than 30 days ago', function()
auto_confirm_y()
local s = pending.store()
local t = s:add({ description = 'Old done task' })
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
@ -32,6 +48,7 @@ describe('archive', function()
end)
it('keeps done tasks completed fewer than 30 days ago', function()
auto_confirm_y()
local s = pending.store()
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
local t = s:add({ description = 'Recent done task' })
@ -42,26 +59,84 @@ describe('archive', function()
assert.are.equal('Recent done task', active[1].description)
end)
it('respects a custom day count', function()
it('respects duration string 7d', function()
auto_confirm_y()
local s = pending.store()
local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400))
local t = s:add({ description = 'Old for 7 days' })
s:update(t.id, { status = 'done', ['end'] = eight_days_ago })
pending.archive(7)
pending.archive('7d')
assert.are.equal(0, #s:active_tasks())
end)
it('keeps tasks within the custom day cutoff', function()
it('respects duration string 2w', function()
auto_confirm_y()
local s = pending.store()
local fifteen_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (15 * 86400))
local t = s:add({ description = 'Old for 2 weeks' })
s:update(t.id, { status = 'done', ['end'] = fifteen_days_ago })
pending.archive('2w')
assert.are.equal(0, #s:active_tasks())
end)
it('respects duration string 2m', function()
auto_confirm_y()
local s = pending.store()
local t = s:add({ description = 'Old for 2 months' })
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive('2m')
assert.are.equal(0, #s:active_tasks())
end)
it('respects bare integer as days (backwards compat)', function()
auto_confirm_y()
local s = pending.store()
local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400))
local t = s:add({ description = 'Old for 7 days' })
s:update(t.id, { status = 'done', ['end'] = eight_days_ago })
pending.archive('7')
assert.are.equal(0, #s:active_tasks())
end)
it('keeps tasks within the custom duration cutoff', function()
auto_confirm_y()
local s = pending.store()
local five_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
local t = s:add({ description = 'Recent for 7 days' })
s:update(t.id, { status = 'done', ['end'] = five_days_ago })
pending.archive(7)
pending.archive('7d')
local active = s:active_tasks()
assert.are.equal(1, #active)
end)
it('errors on invalid duration input', function()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, ...)
table.insert(messages, msg)
return orig_notify(msg, ...)
end
local s = pending.store()
local t = s:add({ description = 'Task' })
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive('xyz')
vim.notify = orig_notify
assert.are.equal(1, #s:tasks())
local found = false
for _, msg in ipairs(messages) do
if msg:find('Invalid duration') then
found = true
break
end
end
assert.is_true(found)
end)
it('never archives pending tasks regardless of age', function()
auto_confirm_y()
local s = pending.store()
s:add({ description = 'Still pending' })
pending.archive()
@ -71,6 +146,7 @@ describe('archive', function()
end)
it('removes deleted tasks past the cutoff', function()
auto_confirm_y()
local s = pending.store()
local t = s:add({ description = 'Old deleted task' })
s:update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' })
@ -80,6 +156,7 @@ describe('archive', function()
end)
it('keeps deleted tasks within the cutoff', function()
auto_confirm_y()
local s = pending.store()
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
local t = s:add({ description = 'Recent deleted' })
@ -89,7 +166,58 @@ describe('archive', function()
assert.are.equal(1, #all)
end)
it('skips confirmation and reports when no tasks match', function()
local input_called = false
vim.ui.input = function()
input_called = true
end
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, ...)
table.insert(messages, msg)
return orig_notify(msg, ...)
end
local s = pending.store()
s:add({ description = 'Still pending' })
pending.archive()
vim.notify = orig_notify
assert.is_false(input_called)
local found = false
for _, msg in ipairs(messages) do
if msg:find('No tasks to archive') then
found = true
break
end
end
assert.is_true(found)
end)
it('does not archive when user declines confirmation', function()
auto_confirm_n()
local s = pending.store()
local t = s:add({ description = 'Old done task' })
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive()
assert.are.equal(1, #s:tasks())
end)
it('does not archive when user cancels confirmation (nil)', function()
vim.ui.input = function(opts, on_confirm)
on_confirm(nil)
end
local s = pending.store()
local t = s:add({ description = 'Old done task' })
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive()
assert.are.equal(1, #s:tasks())
end)
it('reports the correct count in vim.notify', function()
auto_confirm_y()
local s = pending.store()
local messages = {}
local orig_notify = vim.notify
@ -118,7 +246,8 @@ describe('archive', function()
assert.is_true(found)
end)
it('leaves only kept tasks in store.active_tasks after archive', function()
it('leaves only kept tasks in store after archive', function()
auto_confirm_y()
local s = pending.store()
local t1 = s:add({ description = 'Old done' })
s:add({ description = 'Keep pending' })
@ -140,6 +269,7 @@ describe('archive', function()
end)
it('persists archived tasks to disk after unload/reload', function()
auto_confirm_y()
local s = pending.store()
local t = s:add({ description = 'Archived task' })
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
@ -147,4 +277,21 @@ describe('archive', function()
s:load()
assert.are.equal(0, #s:active_tasks())
end)
it('includes the duration in the confirmation prompt', function()
local prompt_text
vim.ui.input = function(opts, on_confirm)
prompt_text = opts.prompt
on_confirm('n')
end
local s = pending.store()
local t = s:add({ description = 'Old' })
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive('3w')
assert.is_not_nil(prompt_text)
assert.truthy(prompt_text:find('21d'))
assert.truthy(prompt_text:find('1 task'))
end)
end)