diff --git a/doc/pending.txt b/doc/pending.txt index d3cf136..fd73e30 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -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* diff --git a/lua/pending/init.lua b/lua/pending/init.lua index d176646..8512210 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -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 .. ']' diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index e234269..9ce4c0d 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -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 diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 6635575..3b29b33 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -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)', diff --git a/lua/pending/views.lua b/lua/pending/views.lua index a9f56bf..32cc2fb 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -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, }) diff --git a/spec/status_spec.lua b/spec/status_spec.lua new file mode 100644 index 0000000..ecbe127 --- /dev/null +++ b/spec/status_spec.lua @@ -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)