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
This commit is contained in:
Barrett Ruth 2026-02-24 18:33:07 -05:00 committed by GitHub
parent 0727a03d41
commit f21658f138
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1137 additions and 30 deletions

View file

@ -88,9 +88,25 @@ local function apply_extmarks(bufnr, line_meta)
for i, m in ipairs(line_meta) do
local row = i - 1
if m.type == 'task' then
if m.due then
local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue'
if m.show_category then
local virt_text
if m.category and m.due then
virt_text = { { m.category .. ' ', 'PendingHeader' }, { m.due, due_hl } }
elseif m.category then
virt_text = { { m.category, 'PendingHeader' } }
elseif m.due then
virt_text = { { m.due, due_hl } }
end
if virt_text then
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
virt_text = virt_text,
virt_text_pos = 'right_align',
})
end
elseif m.due then
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
virt_text = { { m.due, 'PendingDue' } },
virt_text = { { m.due, due_hl } },
virt_text_pos = 'right_align',
})
end
@ -120,6 +136,7 @@ local function setup_highlights()
end
hl('PendingHeader', { bold = true })
hl('PendingDue', { fg = '#888888', italic = true })
hl('PendingOverdue', { fg = '#e06c75', italic = true })
hl('PendingDone', { strikethrough = true, fg = '#666666' })
hl('PendingPriority', { fg = '#e06c75', bold = true })
end

View file

@ -8,7 +8,8 @@
---@field default_category string
---@field date_format string
---@field date_syntax string
---@field gcal? task.GcalConfig
---@field category_order? string[]
---@field gcal? pending.GcalConfig
---@class pending.config
local M = {}
@ -20,6 +21,7 @@ local defaults = {
default_category = 'Inbox',
date_format = '%b %d',
date_syntax = 'due',
category_order = {},
}
---@type pending.Config?

View file

@ -16,7 +16,7 @@ local M = {}
---@return string
local function timestamp()
return os.date('!%Y-%m-%dT%H:%M:%SZ')
return os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
end
---@param lines string[]

View file

@ -41,6 +41,15 @@ function M._setup_buf_mappings(bufnr)
vim.keymap.set('n', 'g?', function()
M.show_help()
end, opts)
vim.keymap.set('n', '!', function()
M.toggle_priority()
end, opts)
vim.keymap.set('n', 'd', function()
M.prompt_date()
end, opts)
vim.keymap.set('n', 'U', function()
M.undo_write()
end, opts)
end
---@param bufnr integer
@ -51,6 +60,17 @@ function M._on_write(bufnr)
buffer.render(bufnr)
end
function M.undo_write()
if not _undo_state then
vim.notify('Nothing to undo.', vim.log.levels.WARN)
return
end
store.replace_tasks(_undo_state)
store.save()
_undo_state = nil
buffer.render(buffer.bufnr())
end
function M.toggle_complete()
local bufnr = buffer.bufnr()
if not bufnr then
@ -62,6 +82,9 @@ function M.toggle_complete()
return
end
local id = meta[row].id
if not id then
return
end
local task = store.get(id)
if not task then
return
@ -86,6 +109,9 @@ function M.toggle_priority()
return
end
local id = meta[row].id
if not id then
return
end
local task = store.get(id)
if not task then
return
@ -107,13 +133,19 @@ function M.prompt_date()
return
end
local id = meta[row].id
if not id then
return
end
vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input)
if not input then
return
end
local due = input ~= '' and input or nil
if due then
if not due:match('^%d%d%d%d%-%d%d%-%d%d$') then
local resolved = parse.resolve_date(due)
if resolved then
due = resolved
elseif not due:match('^%d%d%d%d%-%d%d%-%d%d$') then
vim.notify('Invalid date format. Use YYYY-MM-DD.', vim.log.levels.ERROR)
return
end
@ -170,12 +202,12 @@ function M.archive(days)
local y, mo, d, h, mi, s = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$')
if y then
local t = os.time({
year = tonumber(y),
month = tonumber(mo),
day = tonumber(d),
hour = tonumber(h),
min = tonumber(mi),
sec = tonumber(s),
year = tonumber(y) --[[@as integer]],
month = tonumber(mo) --[[@as integer]],
day = tonumber(d) --[[@as integer]],
hour = tonumber(h) --[[@as integer]],
min = tonumber(mi) --[[@as integer]],
sec = tonumber(s) --[[@as integer]],
})
if t < cutoff then
archived = archived + 1
@ -203,6 +235,9 @@ function M.show_help()
'',
'<CR> Toggle complete/uncomplete',
'<Tab> Switch category/priority view',
'! Toggle priority',
'd Set due date',
'U Undo last write',
'o / O Add new task line',
'dd Delete task (on :w)',
'p / P Paste (duplicates get new IDs)',
@ -212,6 +247,7 @@ function M.show_help()
':Pending add Cat: <text> Quick-add with category',
':Pending sync Push to Google Calendar',
':Pending archive [days] Purge old done tasks',
':Pending undo Undo last write',
'',
'Inline metadata (on new lines before :w):',
' ' .. dk .. ':YYYY-MM-DD Set due date',
@ -256,6 +292,8 @@ function M.command(args)
elseif cmd == 'archive' then
local d = rest ~= '' and tonumber(rest) or nil
M.archive(d)
elseif cmd == 'undo' then
M.undo_write()
else
vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR)
end

View file

@ -10,7 +10,10 @@ local function is_valid_date(s)
if not y then
return false
end
y, m, d = tonumber(y), tonumber(m), tonumber(d)
y, m, d =
tonumber(y), --[[@as integer]]
tonumber(m), --[[@as integer]]
tonumber(d) --[[@as integer]]
if m < 1 or m > 12 then
return false
end
@ -27,6 +30,60 @@ local function date_key()
return config.get().date_syntax or 'due'
end
local weekday_map = {
sun = 1,
mon = 2,
tue = 3,
wed = 4,
thu = 5,
fri = 6,
sat = 7,
}
---@param text string
---@return string|nil
function M.resolve_date(text)
local lower = text:lower()
local today = os.date('*t')
if lower == 'today' then
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]]
end
if lower == 'tomorrow' then
return os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + 1 })
) --[[@as string]]
end
local n = lower:match('^%+(%d+)d$')
if n then
return os.date(
'%Y-%m-%d',
os.time({
year = today.year,
month = today.month,
day = today.day + (
tonumber(n) --[[@as integer]]
),
})
) --[[@as string]]
end
local target_wday = weekday_map[lower]
if target_wday then
local current_wday = today.wday
local delta = (target_wday - current_wday) % 7
return os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + delta })
) --[[@as string]]
end
return nil
end
---@param text string
---@return string description
---@return { due?: string, cat?: string } metadata
@ -39,11 +96,12 @@ function M.body(text)
local metadata = {}
local i = #tokens
local dk = date_key()
local date_pattern = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$'
local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$'
local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$'
while i >= 1 do
local token = tokens[i]
local due_val = token:match(date_pattern)
local due_val = token:match(date_pattern_strict)
if due_val then
if metadata.due then
break
@ -54,15 +112,28 @@ function M.body(text)
metadata.due = due_val
i = i - 1
else
local cat_val = token:match('^cat:(%S+)$')
if cat_val then
if metadata.cat then
local raw_val = token:match(date_pattern_any)
if raw_val then
if metadata.due then
break
end
metadata.cat = cat_val
local resolved = M.resolve_date(raw_val)
if not resolved then
break
end
metadata.due = resolved
i = i - 1
else
break
local cat_val = token:match('^cat:(%S+)$')
if cat_val then
if metadata.cat then
break
end
metadata.cat = cat_val
i = i - 1
else
break
end
end
end
end

View file

@ -16,7 +16,7 @@ local config = require('pending.config')
---@class pending.Data
---@field version integer
---@field next_id integer
---@field tasks task.Task[]
---@field tasks pending.Task[]
---@class pending.store
local M = {}
@ -45,7 +45,7 @@ end
---@return string
local function timestamp()
return os.date('!%Y-%m-%dT%H:%M:%SZ')
return os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
end
---@type table<string, true>
@ -188,7 +188,7 @@ function M.data()
if not _data then
M.load()
end
return _data
return _data --[[@as pending.Data]]
end
---@return pending.Task[]

View file

@ -313,7 +313,7 @@ local function find_or_create_calendar(access_token)
return nil, err
end
for _, item in ipairs(data.items or {}) do
for _, item in ipairs(data and data.items or {}) do
if item.summary == cal_name then
return item.id, nil
end
@ -326,12 +326,13 @@ local function find_or_create_calendar(access_token)
return nil, create_err
end
return created.id, nil
return created and created.id, nil
end
local function next_day(date_str)
local y, m, d = date_str:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) + 86400
local t = os.time({ year = tonumber(y) or 0, month = tonumber(m) or 0, day = tonumber(d) or 0 })
+ 86400
return os.date('%Y-%m-%d', t)
end
@ -354,7 +355,7 @@ local function create_event(access_token, calendar_id, task)
if err then
return nil, err
end
return data.id, nil
return data and data.id, nil
end
local function update_event(access_token, calendar_id, event_id, task)
@ -416,7 +417,7 @@ function M.sync()
else
task._extra = extra
end
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ')
task.modified = tostring(os.date('!%Y-%m-%dT%H:%M:%SZ'))
deleted = deleted + 1
end
elseif task.status == 'pending' and task.due then
@ -432,7 +433,7 @@ function M.sync()
task._extra = {}
end
task._extra._gcal_event_id = new_id
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ')
task.modified = tostring(os.date('!%Y-%m-%dT%H:%M:%SZ'))
created = created + 1
end
end

View file

@ -7,6 +7,8 @@ local config = require('pending.config')
---@field raw_due? string
---@field status? string
---@field category? string
---@field overdue? boolean
---@field show_category? boolean
---@class pending.views
local M = {}
@ -21,8 +23,12 @@ local function format_due(due)
if not y then
return due
end
local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) })
return os.date(config.get().date_format, t)
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[]
@ -66,6 +72,7 @@ end
---@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 = {}
@ -86,6 +93,24 @@ function M.category_view(tasks)
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])
@ -123,6 +148,7 @@ function M.category_view(tasks)
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
@ -134,6 +160,7 @@ end
---@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 = {}
@ -172,6 +199,8 @@ function M.priority_view(tasks)
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