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:
parent
0727a03d41
commit
f21658f138
11 changed files with 1137 additions and 30 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue