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.
This commit is contained in:
Barrett Ruth 2026-02-26 16:30:06 -05:00 committed by GitHub
parent 302bf8126f
commit e62e09f609
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 507 additions and 69 deletions

View file

@ -513,6 +513,57 @@ Fields: ~
|pending.GcalConfig|. Omit this field entirely to
disable Google Calendar sync.
==============================================================================
LUA API *pending-api*
The following functions are available on `require('pending')` for use in
statuslines, autocmds, and other integrations.
*pending.counts()*
pending.counts()
Returns a table of current task counts: >lua
{
overdue = 2, -- pending tasks past their due date/time
today = 1, -- pending tasks due today (not yet overdue)
pending = 10, -- total pending tasks (all statuses)
priority = 3, -- pending tasks with priority > 0
next_due = "2026-03-01", -- earliest future due date, or nil
}
<
The counts are read from a module-local cache that is invalidated on every
`:w`, toggle, date change, archive, undo, and sync. The first call triggers
a lazy `store.load()` if the store has not been loaded yet.
Done, deleted, and `someday` sentinel-dated tasks are excluded from the
`overdue` and `today` counts. The `someday` sentinel is the value of
`someday_date` in |pending-config| (default `9999-12-30`).
*pending.statusline()*
pending.statusline()
Returns a pre-formatted string suitable for embedding in a statusline:
- `"2 overdue, 1 today"` when both overdue and today counts are non-zero
- `"2 overdue"` when only overdue
- `"1 today"` when only today
- `""` (empty string) when nothing is actionable
*pending.has_due()*
pending.has_due()
Returns `true` when `overdue > 0` or `today > 0`. Useful as a conditional
for statusline components that should only render when tasks need attention.
*PendingStatusChanged*
PendingStatusChanged
A |User| autocmd event fired after every count recomputation. Use this to
trigger statusline refreshes or notifications: >lua
vim.api.nvim_create_autocmd('User', {
pattern = 'PendingStatusChanged',
callback = function()
vim.cmd.redrawstatus()
end,
})
<
==============================================================================
RECIPES *pending-recipes*
@ -526,6 +577,52 @@ Configure blink.cmp to use pending.nvim's omnifunc as a completion source: >lua
})
<
Lualine integration: >lua
require('lualine').setup({
sections = {
lualine_x = {
{
function() return require('pending').statusline() end,
cond = function() return require('pending').has_due() end,
},
},
},
})
<
Heirline integration: >lua
local Pending = {
condition = function() return require('pending').has_due() end,
provider = function() return require('pending').statusline() end,
}
<
Manual statusline: >vim
set statusline+=%{%v:lua.require('pending').statusline()%}
<
Startup notification: >lua
vim.api.nvim_create_autocmd('User', {
pattern = 'PendingStatusChanged',
once = true,
callback = function()
local c = require('pending').counts()
if c.overdue > 0 then
vim.notify(c.overdue .. ' overdue task(s)')
end
end,
})
<
Event-driven statusline refresh: >lua
vim.api.nvim_create_autocmd('User', {
pattern = 'PendingStatusChanged',
callback = function()
vim.cmd.redrawstatus()
end,
})
<
==============================================================================
GOOGLE CALENDAR *pending-gcal*

View file

@ -3,11 +3,97 @@ 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()
@ -167,6 +253,7 @@ function M._on_write(bufnr)
table.remove(stack, 1)
end
diff.apply(lines)
M._recompute_counts()
buffer.render(bufnr)
end
@ -179,7 +266,7 @@ function M.undo_write()
end
local state = table.remove(stack)
store.replace_tasks(state)
store.save()
_save_and_notify()
buffer.render(buffer.bufnr())
end
@ -220,7 +307,7 @@ function M.toggle_complete()
end
store.update(id, { status = 'done' })
end
store.save()
_save_and_notify()
buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do
if m.id == id then
@ -251,7 +338,7 @@ function M.toggle_priority()
end
local new_priority = task.priority > 0 and 0 or 1
store.update(id, { priority = new_priority })
store.save()
_save_and_notify()
buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do
if m.id == id then
@ -294,7 +381,7 @@ function M.prompt_date()
end
end
store.update(id, { due = due })
store.save()
_save_and_notify()
buffer.render(bufnr)
end)
end
@ -319,7 +406,7 @@ function M.add(text)
recur = metadata.rec,
recur_mode = metadata.rec_mode,
})
store.save()
_save_and_notify()
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
@ -367,7 +454,7 @@ function M.archive(days)
::skip::
end
store.replace_tasks(kept)
store.save()
_save_and_notify()
vim.notify('Archived ' .. archived .. ' tasks.')
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
@ -375,44 +462,6 @@ function M.archive(days)
end
end
---@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()
@ -422,9 +471,14 @@ 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 is_due_or_overdue(m.raw_due) then
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 = is_overdue(m.raw_due) and '[OVERDUE] ' or '[DUE] '
local label = parse.is_overdue(m.raw_due) and '[OVERDUE] ' or '[DUE] '
table.insert(qf_items, {
bufnr = bufnr,
lnum = lnum,
@ -436,8 +490,12 @@ function M.due()
else
store.load()
for _, task in ipairs(store.active_tasks()) do
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] '
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 .. ']'

View file

@ -516,4 +516,39 @@ function M.command_add(text)
return M.body(text)
end
---@param due string
---@return boolean
function M.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
---@param due string
---@return boolean
function M.is_today(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 false
end
local current_time = string.format('%02d:%02d', now.hour, now.min)
return time_part >= current_time
end
return M

View file

@ -503,6 +503,7 @@ function M.sync()
end
store.save()
require('pending')._recompute_counts()
vim.notify(
string.format(
'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)',

View file

@ -1,4 +1,5 @@
local config = require('pending.config')
local parse = require('pending.parse')
---@class pending.LineMeta
---@field type 'task'|'header'|'blank'
@ -40,25 +41,6 @@ local function format_due(due)
return formatted
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
---@param tasks pending.Task[]
local function sort_tasks(tasks)
table.sort(tasks, function(a, b)
@ -174,7 +156,8 @@ function M.category_view(tasks)
raw_due = task.due,
status = task.status,
category = cat,
overdue = task.status == 'pending' and task.due ~= nil and is_overdue(task.due) or nil,
overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due)
or nil,
recur = task.recur,
})
end
@ -224,7 +207,7 @@ function M.priority_view(tasks)
raw_due = task.due,
status = task.status,
category = task.category,
overdue = task.status == 'pending' and task.due ~= nil and is_overdue(task.due) or nil,
overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) or nil,
show_category = true,
recur = task.recur,
})

264
spec/status_spec.lua Normal file
View file

@ -0,0 +1,264 @@
require('spec.helpers')
local config = require('pending.config')
local parse = require('pending.parse')
local store = require('pending.store')
describe('status', function()
local tmpdir
local pending
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
package.loaded['pending'] = nil
pending = require('pending')
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
store.unload()
package.loaded['pending'] = nil
end)
describe('counts', function()
it('returns zeroes for empty store', function()
store.load()
local c = pending.counts()
assert.are.equal(0, c.overdue)
assert.are.equal(0, c.today)
assert.are.equal(0, c.pending)
assert.are.equal(0, c.priority)
assert.is_nil(c.next_due)
end)
it('counts pending tasks', function()
store.load()
store.add({ description = 'One' })
store.add({ description = 'Two' })
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(2, c.pending)
end)
it('counts priority tasks', function()
store.load()
store.add({ description = 'Urgent', priority = 1 })
store.add({ description = 'Normal' })
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.priority)
end)
it('counts overdue tasks with date-only', function()
store.load()
store.add({ description = 'Old task', due = '2020-01-01' })
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.overdue)
end)
it('counts overdue tasks with datetime', function()
store.load()
store.add({ description = 'Old task', due = '2020-01-01T08:00' })
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.overdue)
end)
it('counts today tasks', function()
store.load()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Today task', due = today })
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.today)
assert.are.equal(0, c.overdue)
end)
it('counts mixed overdue and today', function()
store.load()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Overdue', due = '2020-01-01' })
store.add({ description = 'Today', due = today })
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.overdue)
assert.are.equal(1, c.today)
end)
it('excludes done tasks', function()
store.load()
local t = store.add({ description = 'Done', due = '2020-01-01' })
store.update(t.id, { status = 'done' })
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(0, c.overdue)
assert.are.equal(0, c.pending)
end)
it('excludes deleted tasks', function()
store.load()
local t = store.add({ description = 'Deleted', due = '2020-01-01' })
store.delete(t.id)
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(0, c.overdue)
assert.are.equal(0, c.pending)
end)
it('excludes someday sentinel', function()
store.load()
store.add({ description = 'Someday', due = '9999-12-30' })
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(0, c.overdue)
assert.are.equal(0, c.today)
assert.are.equal(1, c.pending)
end)
it('picks earliest future date as next_due', function()
store.load()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Soon', due = '2099-06-01' })
store.add({ description = 'Sooner', due = '2099-03-01' })
store.add({ description = 'Today', due = today })
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(today, c.next_due)
end)
it('lazy loads on first counts() call', function()
local path = config.get().data_path
local f = io.open(path, 'w')
f:write(vim.json.encode({
version = 1,
next_id = 2,
tasks = {
{
id = 1,
description = 'Overdue',
status = 'pending',
due = '2020-01-01',
entry = '2020-01-01T00:00:00Z',
modified = '2020-01-01T00:00:00Z',
},
},
}))
f:close()
store.unload()
package.loaded['pending'] = nil
pending = require('pending')
local c = pending.counts()
assert.are.equal(1, c.overdue)
end)
end)
describe('statusline', function()
it('returns empty string when nothing actionable', function()
store.load()
store.save()
pending._recompute_counts()
assert.are.equal('', pending.statusline())
end)
it('formats overdue only', function()
store.load()
store.add({ description = 'Old', due = '2020-01-01' })
store.save()
pending._recompute_counts()
assert.are.equal('1 overdue', pending.statusline())
end)
it('formats today only', function()
store.load()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Today', due = today })
store.save()
pending._recompute_counts()
assert.are.equal('1 today', pending.statusline())
end)
it('formats overdue and today', function()
store.load()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Old', due = '2020-01-01' })
store.add({ description = 'Today', due = today })
store.save()
pending._recompute_counts()
assert.are.equal('1 overdue, 1 today', pending.statusline())
end)
end)
describe('has_due', function()
it('returns false when nothing due', function()
store.load()
store.add({ description = 'Future', due = '2099-01-01' })
store.save()
pending._recompute_counts()
assert.is_false(pending.has_due())
end)
it('returns true when overdue', function()
store.load()
store.add({ description = 'Old', due = '2020-01-01' })
store.save()
pending._recompute_counts()
assert.is_true(pending.has_due())
end)
it('returns true when today', function()
store.load()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Now', due = today })
store.save()
pending._recompute_counts()
assert.is_true(pending.has_due())
end)
end)
describe('parse.is_overdue', function()
it('date before today is overdue', function()
assert.is_true(parse.is_overdue('2020-01-01'))
end)
it('date after today is not overdue', function()
assert.is_false(parse.is_overdue('2099-01-01'))
end)
it('today date-only is not overdue', function()
local today = os.date('%Y-%m-%d') --[[@as string]]
assert.is_false(parse.is_overdue(today))
end)
end)
describe('parse.is_today', function()
it('today date-only is today', function()
local today = os.date('%Y-%m-%d') --[[@as string]]
assert.is_true(parse.is_today(today))
end)
it('yesterday is not today', function()
assert.is_false(parse.is_today('2020-01-01'))
end)
it('tomorrow is not today', function()
assert.is_false(parse.is_today('2099-01-01'))
end)
end)
end)