pending.nvim/lua/pending/views.lua
Barrett Ruth 3a35fab6cf feat: overdue highlighting, relative dates, undo write, buffer mappings (#1)
* feat(config): add category_order field

Problem: category display order was always insertion order with no way
to configure it.

Solution: add category_order to config defaults so users can declare a
preferred category ordering; unspecified categories append after.

* feat(parse): add relative date resolution

Problem: due dates required full YYYY-MM-DD input, adding friction for
common cases like "today" or "next monday".

Solution: add resolve_date() supporting today, tomorrow, +Nd, and
weekday abbreviations; extend inline token parsing to resolve relative
values before falling back to strict date validation.

* feat(views): overdue flag, category in priority view, category ordering

Problem: overdue tasks were visually indistinct from upcoming ones;
priority view had no category context; category display order was not
configurable.

Solution: compute overdue meta flag for pending tasks past their due
date; set show_category on priority view task meta; reorder categories
according to config.category_order when present.

* feat(buffer): overdue highlight, category virt text in priority view

Problem: overdue tasks had no visual distinction; priority view showed
no category context alongside due dates.

Solution: add PendingOverdue highlight group; render category name as
right-aligned virtual text in priority view, composited with the due
date when both are present.

* feat(init): undo write and buffer-local default mappings

Problem: _undo_state was captured on every save but never consumed;
toggle_priority and prompt_date had no buffer-local defaults, requiring
manual <Plug> configuration.

Solution: implement undo_write() to restore pre-save task state; add !,
d, and U as buffer-local defaults following fugitive's philosophy of
owning the buffer; expose :Pending undo as a command alias.

* test(views): add views spec

Problem: views.lua had no test coverage.

Solution: add 26 tests covering category_view and priority_view
including sort order, line format, overdue detection, show_category
meta, and category_order config behavior.

* test(archive): add archive spec

Problem: archive had no test coverage.

Solution: add 9 tests covering cutoff logic, custom day counts, pending
task preservation, deleted task cleanup, and notify output.

* docs: add vimdoc

Problem: no :help documentation existed.

Solution: add doc/pending.txt covering all features — commands,
mappings, views, configuration, Google Calendar sync, highlight groups,
data format, and health check — following standard vimdoc conventions.

* ci: format

* fix: resolve lint and type check errors

Problem: selene flagged unused variables in new spec files; LuaLS
flagged os.date/os.time return type mismatches, integer? assignments,
and stale task.Task/task.GcalConfig type references.

Solution: prefix unused spec variables with _ or drop unnecessary
assignments; add --[[@as string/integer]] casts for os.date and
os.time calls; add category_order field to pending.Config annotation;
fix task.GcalConfig -> pending.GcalConfig and task.Task[] ->
pending.Task[]; add nil guards on meta[row].id before store calls;
cast store.data() return to non-optional.

* ci: format

* fix: sync

* ci: format
2026-02-24 18:33:07 -05:00

210 lines
4.9 KiB
Lua

local config = require('pending.config')
---@class pending.LineMeta
---@field type 'task'|'header'|'blank'
---@field id? integer
---@field due? string
---@field raw_due? string
---@field status? string
---@field category? string
---@field overdue? boolean
---@field show_category? boolean
---@class pending.views
local M = {}
---@param due? string
---@return string?
local function format_due(due)
if not due then
return nil
end
local y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
if not y then
return due
end
local t = os.time({
year = tonumber(y) --[[@as integer]],
month = tonumber(m) --[[@as integer]],
day = tonumber(d) --[[@as integer]],
})
return os.date(config.get().date_format, t) --[[@as string]]
end
---@param tasks pending.Task[]
local function sort_tasks(tasks)
table.sort(tasks, function(a, b)
if a.priority ~= b.priority then
return a.priority > b.priority
end
if a.order ~= b.order then
return a.order < b.order
end
return a.id < b.id
end)
end
---@param tasks pending.Task[]
local function sort_tasks_priority(tasks)
table.sort(tasks, function(a, b)
if a.priority ~= b.priority then
return a.priority > b.priority
end
local a_due = a.due or ''
local b_due = b.due or ''
if a_due ~= b_due then
if a_due == '' then
return false
end
if b_due == '' then
return true
end
return a_due < b_due
end
if a.order ~= b.order then
return a.order < b.order
end
return a.id < b.id
end)
end
---@param tasks pending.Task[]
---@return string[] lines
---@return pending.LineMeta[] meta
function M.category_view(tasks)
local today = os.date('%Y-%m-%d') --[[@as string]]
local by_cat = {}
local cat_order = {}
local cat_seen = {}
local done_by_cat = {}
for _, task in ipairs(tasks) do
local cat = task.category or config.get().default_category
if not cat_seen[cat] then
cat_seen[cat] = true
table.insert(cat_order, cat)
by_cat[cat] = {}
done_by_cat[cat] = {}
end
if task.status == 'done' then
table.insert(done_by_cat[cat], task)
else
table.insert(by_cat[cat], task)
end
end
local cfg_order = config.get().category_order
if cfg_order and #cfg_order > 0 then
local ordered = {}
local seen = {}
for _, name in ipairs(cfg_order) do
if cat_seen[name] then
table.insert(ordered, name)
seen[name] = true
end
end
for _, name in ipairs(cat_order) do
if not seen[name] then
table.insert(ordered, name)
end
end
cat_order = ordered
end
for _, cat in ipairs(cat_order) do
sort_tasks(by_cat[cat])
sort_tasks(done_by_cat[cat])
end
local lines = {}
local meta = {}
for i, cat in ipairs(cat_order) do
if i > 1 then
table.insert(lines, '')
table.insert(meta, { type = 'blank' })
end
table.insert(lines, cat)
table.insert(meta, { type = 'header', category = cat })
local all = {}
for _, t in ipairs(by_cat[cat]) do
table.insert(all, t)
end
for _, t in ipairs(done_by_cat[cat]) do
table.insert(all, t)
end
for _, task in ipairs(all) do
local prefix = '/' .. task.id .. '/'
local indent = ' '
local prio = task.priority == 1 and '! ' or ''
local line = prefix .. indent .. prio .. task.description
table.insert(lines, line)
table.insert(meta, {
type = 'task',
id = task.id,
due = format_due(task.due),
raw_due = task.due,
status = task.status,
category = cat,
overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil,
})
end
end
return lines, meta
end
---@param tasks pending.Task[]
---@return string[] lines
---@return pending.LineMeta[] meta
function M.priority_view(tasks)
local today = os.date('%Y-%m-%d') --[[@as string]]
local pending = {}
local done = {}
for _, task in ipairs(tasks) do
if task.status == 'done' then
table.insert(done, task)
else
table.insert(pending, task)
end
end
sort_tasks_priority(pending)
sort_tasks_priority(done)
local lines = {}
local meta = {}
local all = {}
for _, t in ipairs(pending) do
table.insert(all, t)
end
for _, t in ipairs(done) do
table.insert(all, t)
end
for _, task in ipairs(all) do
local prefix = '/' .. task.id .. '/'
local indent = ' '
local prio = task.priority == 1 and '! ' or ''
local line = prefix .. indent .. prio .. task.description
table.insert(lines, line)
table.insert(meta, {
type = 'task',
id = task.id,
due = format_due(task.due),
raw_due = task.due,
status = task.status,
category = task.category,
overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil,
show_category = true,
})
end
return lines, meta
end
return M