From 3b3cdc8965c26ff62cec604d09f49ccdd5d04a23 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:19:36 -0400 Subject: [PATCH] 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. --- doc/pending.txt | 37 ++++++++++++++++---- lua/pending/config.lua | 5 ++- lua/pending/views.lua | 61 ++++++++++++++++++++++++++++++--- spec/views_spec.lua | 78 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 12 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index bf515e4..5cca48e 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -542,11 +542,12 @@ Category view (default): ~ *pending-view-category* `zc` and `zo`. Queue view: ~ *pending-view-queue* - A flat list of all tasks sorted by status (wip → pending → blocked → - done), then by priority, then by due date (tasks without a due date sort - last), then by internal order. Category names are shown as right-aligned virtual - text alongside the due date virtual text so tasks remain identifiable - across categories. The buffer is named `pending://queue`. + A flat list of all tasks sorted by a configurable tiebreak chain + (default: status → priority → due → order → id). See + `view.queue.sort` in |pending-config| for customization. Category + names are shown as right-aligned virtual text alongside the due date + virtual text so tasks remain identifiable across categories. The + buffer is named `pending://queue`. ============================================================================== FILTERS *pending-filters* @@ -749,7 +750,9 @@ loads: >lua order = {}, folding = true, }, - queue = {}, + queue = { + sort = { 'status', 'priority', 'due', 'order', 'id' }, + }, }, keymaps = { close = 'q', @@ -891,6 +894,24 @@ Fields: ~ {queue} (table) *pending.QueueViewConfig* 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 vim.g.pending = { @@ -901,6 +922,10 @@ Fields: ~ order = { 'Work', 'Personal' }, folding = { foldtext = '%c: %n items' }, }, + queue = { + sort = { 'status', 'due', 'priority', + 'order', 'id' }, + }, }, } < diff --git a/lua/pending/config.lua b/lua/pending/config.lua index dfce286..71b0bc5 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -87,6 +87,7 @@ ---@field hide_done_categories? boolean ---@class pending.QueueViewConfig +---@field sort? string[] ---@class pending.ViewConfig ---@field default? 'category'|'priority' @@ -133,7 +134,9 @@ local defaults = { folding = true, hide_done_categories = false, }, - queue = {}, + queue = { + sort = { 'status', 'priority', 'due', 'order', 'id' }, + }, }, keymaps = { close = 'q', diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 1b8e303..c31879e 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -106,17 +106,23 @@ local function sort_tasks(tasks) end) end ----@param tasks pending.Task[] -local function sort_tasks_priority(tasks) - table.sort(tasks, function(a, b) +---@type table +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 @@ -128,11 +134,56 @@ local function sort_tasks_priority(tasks) 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 a.id < b.id - 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[] diff --git a/spec/views_spec.lua b/spec/views_spec.lua index 115eb84..1305afa 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -529,5 +529,83 @@ describe('views', function() end assert.is_nil(task_meta.recur) 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)