pending.nvim/lua/pending/views.lua
Barrett Ruth 969dbd299f
feat(views): make queue view sort order configurable (#154)
Problem: the queue/priority view sort in `sort_tasks_priority()` uses a
hardcoded tiebreak chain (status, priority, due, order, id). Users who
care more about due dates than priority have no way to reorder it.

Solution: add `view.queue.sort` config field (string[]) that defines an
ordered tiebreak chain. `build_queue_comparator()` maps each key to a
comparison function and returns a single comparator. Unknown keys emit a
`log.warn`. The default matches the previous hardcoded behavior.
2026-03-12 20:29:04 -04:00

349 lines
8.6 KiB
Lua

local config = require('pending.config')
local forge = require('pending.forge')
local parse = require('pending.parse')
---@class pending.ForgeLineMeta
---@field ref pending.ForgeRef
---@field cache? pending.ForgeCache
---@field col_start integer
---@field col_end integer
---@class pending.LineMeta
---@field type 'task'|'header'|'blank'|'filter'
---@field id? integer
---@field due? string
---@field raw_due? string
---@field status? pending.TaskStatus
---@field category? string
---@field overdue? boolean
---@field show_category? boolean
---@field priority? integer
---@field recur? string
---@field forge_spans? pending.ForgeLineMeta[]
---@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, hh, mm = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d)$')
if not y then
y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
end
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]],
})
local formatted = os.date(config.get().date_format, t) --[[@as string]]
if hh then
formatted = formatted .. ' ' .. hh .. ':' .. mm
end
return formatted
end
---@param task pending.Task
---@param prefix_len integer
---@return pending.ForgeLineMeta[]?
local function compute_forge_spans(task, prefix_len)
local refs = forge.find_refs(task.description)
if #refs == 0 then
return nil
end
local cache = task._extra and task._extra._forge_cache or nil
local spans = {}
for _, r in ipairs(refs) do
table.insert(spans, {
ref = r.ref,
cache = cache,
col_start = prefix_len + r.start_byte,
col_end = prefix_len + r.end_byte,
})
end
return spans
end
---@type table<string, integer>
local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3 }
---@param task pending.Task
---@return string
local function state_char(task)
if task.status == 'done' then
return 'x'
elseif task.status == 'wip' then
return '>'
elseif task.status == 'blocked' then
return '='
elseif task.priority > 0 then
return '!'
end
return ' '
end
---@param tasks pending.Task[]
local function sort_tasks(tasks)
table.sort(tasks, function(a, b)
local ra = status_rank[a.status] or 1
local rb = status_rank[b.status] or 1
if ra ~= rb then
return ra < rb
end
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
---@type table<string, fun(a: pending.Task, b: pending.Task): boolean?>
local sort_key_comparators = {
status = function(a, b)
local ra = status_rank[a.status] or 1
local rb = status_rank[b.status] or 1
if ra ~= rb then
return ra < rb
end
return nil
end,
priority = function(a, b)
if a.priority ~= b.priority then
return a.priority > b.priority
end
return nil
end,
due = function(a, b)
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
return nil
end,
order = function(a, b)
if a.order ~= b.order then
return a.order < b.order
end
return nil
end,
id = function(a, b)
if a.id ~= b.id then
return a.id < b.id
end
return nil
end,
age = function(a, b)
if a.id ~= b.id then
return a.id < b.id
end
return nil
end,
}
---@return fun(a: pending.Task, b: pending.Task): boolean
local function build_queue_comparator()
local log = require('pending.log')
local keys = config.get().view.queue.sort or { 'status', 'priority', 'due', 'order', 'id' }
local comparators = {}
for _, key in ipairs(keys) do
local cmp = sort_key_comparators[key]
if cmp then
table.insert(comparators, cmp)
else
log.warn('unknown queue sort key: ' .. key)
end
end
return function(a, b)
for _, cmp in ipairs(comparators) do
local result = cmp(a, b)
if result ~= nil then
return result
end
end
return false
end
end
---@param tasks pending.Task[]
local function sort_tasks_priority(tasks)
local cmp = build_queue_comparator()
table.sort(tasks, cmp)
end
---@param tasks pending.Task[]
---@return string[] lines
---@return pending.LineMeta[] meta
---@return table<integer, true> done_cat_hidden_ids
function M.category_view(tasks)
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' or task.status == 'deleted' then
table.insert(done_by_cat[cat], task)
else
table.insert(by_cat[cat], task)
end
end
local cfg_order = config.get().view.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
local hide_done = config.get().view.category.hide_done_categories
local done_cat_hidden = {} ---@type table<integer, true>
for _, cat in ipairs(cat_order) do
sort_tasks(by_cat[cat])
sort_tasks(done_by_cat[cat])
end
local lines = {}
local meta = {}
local rendered = 0
for _, cat in ipairs(cat_order) do
if hide_done and #by_cat[cat] == 0 and #done_by_cat[cat] > 0 then
for _, t in ipairs(done_by_cat[cat]) do
done_cat_hidden[t.id] = true
end
goto next_cat
end
if rendered > 0 then
table.insert(lines, '')
table.insert(meta, { type = 'blank' })
end
rendered = rendered + 1
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 state = state_char(task)
local line = prefix .. '- [' .. state .. '] ' .. task.description
local prefix_len = #prefix + #('- [' .. state .. '] ')
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,
priority = task.priority,
overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil,
recur = task.recur,
forge_spans = compute_forge_spans(task, prefix_len),
})
end
::next_cat::
end
return lines, meta, done_cat_hidden
end
---@param tasks pending.Task[]
---@return string[] lines
---@return pending.LineMeta[] meta
function M.priority_view(tasks)
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 state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ')
local line = prefix .. '- [' .. state .. '] ' .. task.description
local prefix_len = #prefix + #('- [' .. state .. '] ')
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,
priority = task.priority,
overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil,
show_category = true,
recur = task.recur,
forge_ref = task._extra and task._extra._forge_ref or nil,
forge_cache = task._extra and task._extra._forge_cache or nil,
forge_spans = compute_forge_spans(task, prefix_len),
})
end
return lines, meta
end
return M