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.
This commit is contained in:
Barrett Ruth 2026-03-12 20:19:36 -04:00 committed by Barrett Ruth
parent 283f93eda1
commit 969dbd299f
Signed by: barrett
GPG key ID: A6C96C9349D2FC81
4 changed files with 169 additions and 12 deletions

View file

@ -542,11 +542,12 @@ Category view (default): ~ *pending-view-category*
`zc` and `zo`. `zc` and `zo`.
Queue view: ~ *pending-view-queue* Queue view: ~ *pending-view-queue*
A flat list of all tasks sorted by status (wip → pending → blocked → A flat list of all tasks sorted by a configurable tiebreak chain
done), then by priority, then by due date (tasks without a due date sort (default: status → priority → due → order → id). See
last), then by internal order. Category names are shown as right-aligned virtual `view.queue.sort` in |pending-config| for customization. Category
text alongside the due date virtual text so tasks remain identifiable names are shown as right-aligned virtual text alongside the due date
across categories. The buffer is named `pending://queue`. virtual text so tasks remain identifiable across categories. The
buffer is named `pending://queue`.
============================================================================== ==============================================================================
FILTERS *pending-filters* FILTERS *pending-filters*
@ -749,7 +750,9 @@ loads: >lua
order = {}, order = {},
folding = true, folding = true,
}, },
queue = {}, queue = {
sort = { 'status', 'priority', 'due', 'order', 'id' },
},
}, },
keymaps = { keymaps = {
close = 'q', close = 'q',
@ -891,6 +894,24 @@ Fields: ~
{queue} (table) *pending.QueueViewConfig* {queue} (table) *pending.QueueViewConfig*
Queue (priority) view settings. Queue (priority) view settings.
{sort} (string[], default:
`{ 'status', 'priority', 'due',
'order', 'id' }`)
Ordered tiebreak chain for the
queue view sort. Each element is a
sort key; the comparator walks the
list and returns on the first
non-equal comparison. Valid keys:
`status` wip < pending <
blocked < done
`priority` higher number first
`due` sooner first, no-due
last
`order` ascending
`id` ascending
`age` alias for `id`
Unknown keys are ignored with a
warning.
Examples: >lua Examples: >lua
vim.g.pending = { vim.g.pending = {
@ -901,6 +922,10 @@ Fields: ~
order = { 'Work', 'Personal' }, order = { 'Work', 'Personal' },
folding = { foldtext = '%c: %n items' }, folding = { foldtext = '%c: %n items' },
}, },
queue = {
sort = { 'status', 'due', 'priority',
'order', 'id' },
},
}, },
} }
< <

View file

@ -87,6 +87,7 @@
---@field hide_done_categories? boolean ---@field hide_done_categories? boolean
---@class pending.QueueViewConfig ---@class pending.QueueViewConfig
---@field sort? string[]
---@class pending.ViewConfig ---@class pending.ViewConfig
---@field default? 'category'|'priority' ---@field default? 'category'|'priority'
@ -133,7 +134,9 @@ local defaults = {
folding = true, folding = true,
hide_done_categories = false, hide_done_categories = false,
}, },
queue = {}, queue = {
sort = { 'status', 'priority', 'due', 'order', 'id' },
},
}, },
keymaps = { keymaps = {
close = 'q', close = 'q',

View file

@ -106,17 +106,23 @@ local function sort_tasks(tasks)
end) end)
end end
---@param tasks pending.Task[] ---@type table<string, fun(a: pending.Task, b: pending.Task): boolean?>
local function sort_tasks_priority(tasks) local sort_key_comparators = {
table.sort(tasks, function(a, b) status = function(a, b)
local ra = status_rank[a.status] or 1 local ra = status_rank[a.status] or 1
local rb = status_rank[b.status] or 1 local rb = status_rank[b.status] or 1
if ra ~= rb then if ra ~= rb then
return ra < rb return ra < rb
end end
return nil
end,
priority = function(a, b)
if a.priority ~= b.priority then if a.priority ~= b.priority then
return a.priority > b.priority return a.priority > b.priority
end end
return nil
end,
due = function(a, b)
local a_due = a.due or '' local a_due = a.due or ''
local b_due = b.due or '' local b_due = b.due or ''
if a_due ~= b_due then if a_due ~= b_due then
@ -128,11 +134,56 @@ local function sort_tasks_priority(tasks)
end end
return a_due < b_due return a_due < b_due
end end
return nil
end,
order = function(a, b)
if a.order ~= b.order then if a.order ~= b.order then
return a.order < b.order return a.order < b.order
end end
return a.id < b.id return nil
end) 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 end
---@param tasks pending.Task[] ---@param tasks pending.Task[]

View file

@ -529,5 +529,83 @@ describe('views', function()
end end
assert.is_nil(task_meta.recur) assert.is_nil(task_meta.recur)
end) end)
it('sorts by due before priority when sort config is reordered', function()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
view = { queue = { sort = { 'status', 'due', 'priority', 'order', 'id' } } },
}
config.reset()
s:add({ description = 'High no due', category = 'Work', priority = 2 })
s:add({ description = 'Low with due', category = 'Work', priority = 0, due = '2050-01-01' })
local lines, meta = views.priority_view(s:active_tasks())
local due_row, nodue_row
for i, m in ipairs(meta) do
if m.type == 'task' then
if lines[i]:find('Low with due') then
due_row = i
elseif lines[i]:find('High no due') then
nodue_row = i
end
end
end
assert.is_true(due_row < nodue_row)
end)
it('uses default sort when config sort is nil', function()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
view = { queue = {} },
}
config.reset()
s:add({ description = 'Low', category = 'Work', priority = 0 })
s:add({ description = 'High', category = 'Work', priority = 1 })
local lines, meta = views.priority_view(s:active_tasks())
local high_row, low_row
for i, m in ipairs(meta) do
if m.type == 'task' then
if lines[i]:find('High') then
high_row = i
elseif lines[i]:find('Low') then
low_row = i
end
end
end
assert.is_true(high_row < low_row)
end)
it('ignores unknown sort keys with a warning', function()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
view = { queue = { sort = { 'bogus', 'status', 'id' } } },
}
config.reset()
s:add({ description = 'A', category = 'Work' })
s:add({ description = 'B', category = 'Work' })
local lines = views.priority_view(s:active_tasks())
assert.is_true(#lines == 2)
end)
it('supports age sort key as alias for id', function()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
view = { queue = { sort = { 'age' } } },
}
config.reset()
s:add({ description = 'Older', category = 'Work' })
s:add({ description = 'Newer', category = 'Work' })
local lines, meta = views.priority_view(s:active_tasks())
local older_row, newer_row
for i, m in ipairs(meta) do
if m.type == 'task' then
if lines[i]:find('Older') then
older_row = i
elseif lines[i]:find('Newer') then
newer_row = i
end
end
end
assert.is_true(older_row < newer_row)
end)
end) end)
end) end)