pending.nvim/lua/pending/init.lua
Barrett Ruth cd1cd1afd4 feat: statusline API, counts, and PendingStatusChanged event (#40)
Problem: no way to know about overdue or due-today tasks without
opening :Pending. No ambient awareness for statusline plugins.

Solution: add counts(), statusline(), and has_due() public API
functions backed by a module-local cache that recomputes after every
store.save() and store.load(). Fire a User PendingStatusChanged event
on every recompute. Extract is_overdue() and is_today() from duplicate
locals into parse.lua as public functions. Refactor views.lua and
init.lua to use the shared date logic. Add vimdoc API section and
integration recipes for lualine, heirline, manual statusline, startup
notification, and event-driven refresh.
2026-02-26 16:30:06 -05:00

541 lines
13 KiB
Lua

local buffer = require('pending.buffer')
local diff = require('pending.diff')
local parse = require('pending.parse')
local store = require('pending.store')
---@class pending.Counts
---@field overdue integer
---@field today integer
---@field pending integer
---@field priority integer
---@field next_due? string
---@class pending.init
local M = {}
local UNDO_MAX = 20
---@type pending.Counts?
local _counts = nil
---@return nil
function M._recompute_counts()
local cfg = require('pending.config').get()
local someday = cfg.someday_date
local overdue = 0
local today = 0
local pending = 0
local priority = 0
local next_due = nil ---@type string?
local today_str = os.date('%Y-%m-%d') --[[@as string]]
for _, task in ipairs(store.active_tasks()) do
if task.status == 'pending' then
pending = pending + 1
if task.priority > 0 then
priority = priority + 1
end
if task.due and task.due ~= someday then
if parse.is_overdue(task.due) then
overdue = overdue + 1
elseif parse.is_today(task.due) then
today = today + 1
end
local date_part = task.due:match('^(%d%d%d%d%-%d%d%-%d%d)') or task.due
if date_part >= today_str and (not next_due or task.due < next_due) then
next_due = task.due
end
end
end
end
_counts = {
overdue = overdue,
today = today,
pending = pending,
priority = priority,
next_due = next_due,
}
vim.api.nvim_exec_autocmds('User', { pattern = 'PendingStatusChanged' })
end
---@return nil
local function _save_and_notify()
store.save()
M._recompute_counts()
end
---@return pending.Counts
function M.counts()
if not _counts then
store.load()
M._recompute_counts()
end
return _counts --[[@as pending.Counts]]
end
---@return string
function M.statusline()
local c = M.counts()
if c.overdue > 0 and c.today > 0 then
return c.overdue .. ' overdue, ' .. c.today .. ' today'
elseif c.overdue > 0 then
return c.overdue .. ' overdue'
elseif c.today > 0 then
return c.today .. ' today'
end
return ''
end
---@return boolean
function M.has_due()
local c = M.counts()
return c.overdue > 0 or c.today > 0
end
---@return integer bufnr
function M.open()
local bufnr = buffer.open()
M._setup_autocmds(bufnr)
M._setup_buf_mappings(bufnr)
return bufnr
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', {
group = group,
buffer = bufnr,
callback = function()
M._on_write(bufnr)
end,
})
vim.api.nvim_create_autocmd('BufEnter', {
group = group,
buffer = bufnr,
callback = function()
if not vim.bo[bufnr].modified then
store.load()
buffer.render(bufnr)
end
end,
})
vim.api.nvim_create_autocmd('WinClosed', {
group = group,
callback = function(ev)
if tonumber(ev.match) == buffer.winid() then
buffer.clear_winid()
end
end,
})
end
---@param bufnr integer
---@return nil
function M._setup_buf_mappings(bufnr)
local cfg = require('pending.config').get()
local km = cfg.keymaps
local opts = { buffer = bufnr, silent = true }
---@type table<string, fun()>
local actions = {
close = function()
buffer.close()
end,
toggle = function()
M.toggle_complete()
end,
view = function()
buffer.toggle_view()
end,
priority = function()
M.toggle_priority()
end,
date = function()
M.prompt_date()
end,
undo = function()
M.undo_write()
end,
open_line = function()
buffer.open_line(false)
end,
open_line_above = function()
buffer.open_line(true)
end,
}
for name, fn in pairs(actions) do
local key = km[name]
if key and key ~= false then
vim.keymap.set('n', key --[[@as string]], fn, opts)
end
end
local textobj = require('pending.textobj')
---@type table<string, { modes: string[], fn: fun(count: integer), visual_fn?: fun(count: integer) }>
local textobjs = {
a_task = {
modes = { 'o', 'x' },
fn = textobj.a_task,
visual_fn = textobj.a_task_visual,
},
i_task = {
modes = { 'o', 'x' },
fn = textobj.i_task,
visual_fn = textobj.i_task_visual,
},
a_category = {
modes = { 'o', 'x' },
fn = textobj.a_category,
visual_fn = textobj.a_category_visual,
},
i_category = {
modes = { 'o', 'x' },
fn = textobj.i_category,
visual_fn = textobj.i_category_visual,
},
}
for name, spec in pairs(textobjs) do
local key = km[name]
if key and key ~= false then
for _, mode in ipairs(spec.modes) do
if mode == 'x' and spec.visual_fn then
vim.keymap.set(mode, key --[[@as string]], function()
spec.visual_fn(vim.v.count1)
end, opts)
else
vim.keymap.set(mode, key --[[@as string]], function()
spec.fn(vim.v.count1)
end, opts)
end
end
end
end
---@type table<string, fun(count: integer)>
local motions = {
next_header = textobj.next_header,
prev_header = textobj.prev_header,
next_task = textobj.next_task,
prev_task = textobj.prev_task,
}
for name, fn in pairs(motions) do
local key = km[name]
if cfg.debug then
vim.notify(
('[pending] mapping motion %s → %s (buf=%d)'):format(name, key or 'nil', bufnr),
vim.log.levels.INFO
)
end
if key and key ~= false then
vim.keymap.set({ 'n', 'x', 'o' }, key --[[@as string]], function()
fn(vim.v.count1)
end, opts)
end
end
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()
local stack = store.undo_stack()
table.insert(stack, snapshot)
if #stack > UNDO_MAX then
table.remove(stack, 1)
end
diff.apply(lines)
M._recompute_counts()
buffer.render(bufnr)
end
---@return nil
function M.undo_write()
local stack = store.undo_stack()
if #stack == 0 then
vim.notify('Nothing to undo.', vim.log.levels.WARN)
return
end
local state = table.remove(stack)
store.replace_tasks(state)
_save_and_notify()
buffer.render(buffer.bufnr())
end
---@return nil
function M.toggle_complete()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then
return
end
local id = meta[row].id
if not id then
return
end
local task = store.get(id)
if not task then
return
end
if task.status == 'done' then
store.update(id, { status = 'pending', ['end'] = vim.NIL })
else
if task.recur and task.due then
local recur = require('pending.recur')
local mode = task.recur_mode or 'scheduled'
local next_date = recur.next_due(task.due, task.recur, mode)
store.add({
description = task.description,
category = task.category,
priority = task.priority,
due = next_date,
recur = task.recur,
recur_mode = task.recur_mode,
})
end
store.update(id, { status = 'done' })
end
_save_and_notify()
buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do
if m.id == id then
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
break
end
end
end
---@return nil
function M.toggle_priority()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then
return
end
local id = meta[row].id
if not id then
return
end
local task = store.get(id)
if not task then
return
end
local new_priority = task.priority > 0 and 0 or 1
store.update(id, { priority = new_priority })
_save_and_notify()
buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do
if m.id == id then
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
break
end
end
end
---@return nil
function M.prompt_date()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then
return
end
local id = meta[row].id
if not id then
return
end
vim.ui.input({ prompt = 'Due date (today, +3d, fri@2pm, etc.): ' }, function(input)
if not input then
return
end
local due = input ~= '' and input or nil
if due then
local resolved = parse.resolve_date(due)
if resolved then
due = resolved
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
store.update(id, { due = due })
_save_and_notify()
buffer.render(bufnr)
end)
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)
return
end
store.load()
local description, metadata = parse.command_add(text)
if not description or description == '' then
vim.notify('Pending must have a description.', vim.log.levels.ERROR)
return
end
store.add({
description = description,
category = metadata.cat,
due = metadata.due,
recur = metadata.rec,
recur_mode = metadata.rec_mode,
})
_save_and_notify()
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
end
vim.notify('Pending added: ' .. description)
end
---@return nil
function M.sync()
local ok, gcal = pcall(require, 'pending.sync.gcal')
if not ok then
vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR)
return
end
gcal.sync()
end
---@param days? integer
---@return nil
function M.archive(days)
days = days or 30
local cutoff = os.time() - (days * 86400)
local tasks = store.tasks()
local archived = 0
local kept = {}
for _, task in ipairs(tasks) do
if (task.status == 'done' or task.status == 'deleted') and task['end'] then
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) --[[@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
goto skip
end
end
end
table.insert(kept, task)
::skip::
end
store.replace_tasks(kept)
_save_and_notify()
vim.notify('Archived ' .. archived .. ' tasks.')
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
end
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
local qf_items = {}
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 (parse.is_overdue(m.raw_due) or parse.is_today(m.raw_due))
then
local task = store.get(m.id or 0)
local label = parse.is_overdue(m.raw_due) and '[OVERDUE] ' or '[DUE] '
table.insert(qf_items, {
bufnr = bufnr,
lnum = lnum,
col = 1,
text = label .. (task and task.description or ''),
})
end
end
else
store.load()
for _, task in ipairs(store.active_tasks()) do
if
task.status == 'pending'
and task.due
and (parse.is_overdue(task.due) or parse.is_today(task.due))
then
local label = parse.is_overdue(task.due) and '[OVERDUE] ' or '[DUE] '
local text = label .. task.description
if task.category then
text = text .. ' [' .. task.category .. ']'
end
table.insert(qf_items, { text = text })
end
end
end
if #qf_items == 0 then
vim.notify('No due or overdue tasks.')
return
end
vim.fn.setqflist(qf_items, 'r')
vim.cmd('copen')
end
---@param args string
---@return nil
function M.command(args)
if not args or args == '' then
M.open()
return
end
local cmd, rest = args:match('^(%S+)%s*(.*)')
if cmd == 'add' then
M.add(rest)
elseif cmd == 'sync' then
M.sync()
elseif cmd == 'archive' then
local d = rest ~= '' and tonumber(rest) or nil
M.archive(d)
elseif cmd == 'due' then
M.due()
elseif cmd == 'undo' then
M.undo_write()
else
vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR)
end
end
return M