feat: time-aware due dates, persistent undo, @return audit

Problem: Due dates had no time component, the undo stack was lost on
restart and stored in a separate file, and many public functions lacked
required @return annotations.

Solution: Add YYYY-MM-DDThh:mm support across parse, views, recur,
complete, and init with time-aware overdue checks. Merge the undo stack
into the task store JSON so a single file holds all state. Add @return
nil annotations to all 27 void public functions across every module.
This commit is contained in:
Barrett Ruth 2026-02-25 20:06:11 -05:00
parent c69afacc87
commit ee2d125846
11 changed files with 369 additions and 118 deletions

View file

@ -1,4 +1,5 @@
local buffer = require('pending.buffer')
local config = require('pending.config')
local diff = require('pending.diff')
local parse = require('pending.parse')
local store = require('pending.store')
@ -6,8 +7,6 @@ local store = require('pending.store')
---@class pending.init
local M = {}
---@type pending.Task[][]
local _undo_states = {}
local UNDO_MAX = 20
---@return integer bufnr
@ -19,6 +18,7 @@ function M.open()
end
---@param bufnr integer
---@return nil
function M._setup_autocmds(bufnr)
local group = vim.api.nvim_create_augroup('PendingBuffer', { clear = true })
vim.api.nvim_create_autocmd('BufWriteCmd', {
@ -49,6 +49,7 @@ function M._setup_autocmds(bufnr)
end
---@param bufnr integer
---@return nil
function M._setup_buf_mappings(bufnr)
local cfg = require('pending.config').get()
local km = cfg.keymaps
@ -91,28 +92,33 @@ function M._setup_buf_mappings(bufnr)
end
---@param bufnr integer
---@return nil
function M._on_write(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local snapshot = store.snapshot()
table.insert(_undo_states, snapshot)
if #_undo_states > UNDO_MAX then
table.remove(_undo_states, 1)
local stack = store.undo_stack()
table.insert(stack, snapshot)
if #stack > UNDO_MAX then
table.remove(stack, 1)
end
diff.apply(lines)
buffer.render(bufnr)
end
---@return nil
function M.undo_write()
if #_undo_states == 0 then
local stack = store.undo_stack()
if #stack == 0 then
vim.notify('Nothing to undo.', vim.log.levels.WARN)
return
end
local state = table.remove(_undo_states)
local state = table.remove(stack)
store.replace_tasks(state)
store.save()
buffer.render(buffer.bufnr())
end
---@return nil
function M.toggle_complete()
local bufnr = buffer.bufnr()
if not bufnr then
@ -137,9 +143,7 @@ function M.toggle_complete()
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)
local next_date = recur.next_due(task.due, task.recur, mode)
store.add({
description = task.description,
category = task.category,
@ -161,6 +165,7 @@ function M.toggle_complete()
end
end
---@return nil
function M.toggle_priority()
local bufnr = buffer.bufnr()
if not bufnr then
@ -191,6 +196,7 @@ function M.toggle_priority()
end
end
---@return nil
function M.prompt_date()
local bufnr = buffer.bufnr()
if not bufnr then
@ -205,7 +211,7 @@ function M.prompt_date()
if not id then
return
end
vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input)
vim.ui.input({ prompt = 'Due date (YYYY-MM-DD[Thh:mm]): ' }, function(input)
if not input then
return
end
@ -214,8 +220,11 @@ function M.prompt_date()
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)
elseif
not due:match('^%d%d%d%d%-%d%d%-%d%d$')
and not due:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$')
then
vim.notify('Invalid date format. Use YYYY-MM-DD or YYYY-MM-DDThh:mm.', vim.log.levels.ERROR)
return
end
end
@ -226,6 +235,7 @@ function M.prompt_date()
end
---@param text string
---@return nil
function M.add(text)
if not text or text == '' then
vim.notify('Usage: :Pending add <description>', vim.log.levels.ERROR)
@ -252,6 +262,7 @@ function M.add(text)
vim.notify('Pending added: ' .. description)
end
---@return nil
function M.sync()
local ok, gcal = pcall(require, 'pending.sync.gcal')
if not ok then
@ -262,6 +273,7 @@ function M.sync()
end
---@param days? integer
---@return nil
function M.archive(days)
days = days or 30
local cutoff = os.time() - (days * 86400)
@ -298,8 +310,46 @@ function M.archive(days)
end
end
function M.due()
---@param due string
---@return boolean
local function is_due_or_overdue(due)
local now = os.date('*t') --[[@as osdate]]
local today = os.date('%Y-%m-%d') --[[@as string]]
local date_part, time_part = due:match('^(.+)T(.+)$')
if not date_part then
return due <= today
end
if date_part < today then
return true
end
if date_part > today then
return false
end
local current_time = string.format('%02d:%02d', now.hour, now.min)
return time_part <= current_time
end
---@param due string
---@return boolean
local function is_overdue(due)
local now = os.date('*t') --[[@as osdate]]
local today = os.date('%Y-%m-%d') --[[@as string]]
local date_part, time_part = due:match('^(.+)T(.+)$')
if not date_part then
return due < today
end
if date_part < today then
return true
end
if date_part > today then
return false
end
local current_time = string.format('%02d:%02d', now.hour, now.min)
return time_part < current_time
end
---@return nil
function M.due()
local bufnr = buffer.bufnr()
local is_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr)
local meta = is_valid and buffer.meta() or nil
@ -307,9 +357,9 @@ function M.due()
if meta and bufnr then
for lnum, m in ipairs(meta) do
if m.type == 'task' and m.raw_due and m.status ~= 'done' and m.raw_due <= today then
if m.type == 'task' and m.raw_due and m.status ~= 'done' and is_due_or_overdue(m.raw_due) then
local task = store.get(m.id or 0)
local label = m.raw_due < today and '[OVERDUE] ' or '[DUE] '
local label = is_overdue(m.raw_due) and '[OVERDUE] ' or '[DUE] '
table.insert(qf_items, {
bufnr = bufnr,
lnum = lnum,
@ -321,8 +371,8 @@ function M.due()
else
store.load()
for _, task in ipairs(store.active_tasks()) do
if task.status == 'pending' and task.due and task.due <= today then
local label = task.due < today and '[OVERDUE] ' or '[DUE] '
if task.status == 'pending' and task.due and is_due_or_overdue(task.due) then
local label = is_overdue(task.due) and '[OVERDUE] ' or '[DUE] '
local text = label .. task.description
if task.category then
text = text .. ' [' .. task.category .. ']'
@ -342,6 +392,7 @@ function M.due()
end
---@param args string
---@return nil
function M.command(args)
if not args or args == '' then
M.open()