pending.nvim/lua/pending/store.lua
Barrett Ruth 3a35fab6cf 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
2026-02-24 18:33:07 -05:00

296 lines
6 KiB
Lua

local config = require('pending.config')
---@class pending.Task
---@field id integer
---@field description string
---@field status 'pending'|'done'|'deleted'
---@field category? string
---@field priority integer
---@field due? string
---@field entry string
---@field modified string
---@field end? string
---@field order integer
---@field _extra? table<string, any>
---@class pending.Data
---@field version integer
---@field next_id integer
---@field tasks pending.Task[]
---@class pending.store
local M = {}
local SUPPORTED_VERSION = 1
---@type pending.Data?
local _data = nil
---@return pending.Data
local function empty_data()
return {
version = SUPPORTED_VERSION,
next_id = 1,
tasks = {},
}
end
---@param path string
local function ensure_dir(path)
local dir = vim.fn.fnamemodify(path, ':h')
if vim.fn.isdirectory(dir) == 0 then
vim.fn.mkdir(dir, 'p')
end
end
---@return string
local function timestamp()
return os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
end
---@type table<string, true>
local known_fields = {
id = true,
description = true,
status = true,
category = true,
priority = true,
due = true,
entry = true,
modified = true,
['end'] = true,
order = true,
}
---@param task pending.Task
---@return table
local function task_to_table(task)
local t = {
id = task.id,
description = task.description,
status = task.status,
entry = task.entry,
modified = task.modified,
}
if task.category then
t.category = task.category
end
if task.priority and task.priority ~= 0 then
t.priority = task.priority
end
if task.due then
t.due = task.due
end
if task['end'] then
t['end'] = task['end']
end
if task.order and task.order ~= 0 then
t.order = task.order
end
if task._extra then
for k, v in pairs(task._extra) do
t[k] = v
end
end
return t
end
---@param t table
---@return pending.Task
local function table_to_task(t)
local task = {
id = t.id,
description = t.description,
status = t.status or 'pending',
category = t.category,
priority = t.priority or 0,
due = t.due,
entry = t.entry,
modified = t.modified,
['end'] = t['end'],
order = t.order or 0,
_extra = {},
}
for k, v in pairs(t) do
if not known_fields[k] then
task._extra[k] = v
end
end
if next(task._extra) == nil then
task._extra = nil
end
return task
end
---@return pending.Data
function M.load()
local path = config.get().data_path
local f = io.open(path, 'r')
if not f then
_data = empty_data()
return _data
end
local content = f:read('*a')
f:close()
if content == '' then
_data = empty_data()
return _data
end
local ok, decoded = pcall(vim.json.decode, content)
if not ok then
error('pending.nvim: failed to parse ' .. path .. ': ' .. tostring(decoded))
end
if decoded.version and decoded.version > SUPPORTED_VERSION then
error(
'pending.nvim: data file version '
.. decoded.version
.. ' is newer than supported version '
.. SUPPORTED_VERSION
.. '. Please update the plugin.'
)
end
_data = {
version = decoded.version or SUPPORTED_VERSION,
next_id = decoded.next_id or 1,
tasks = {},
}
for _, t in ipairs(decoded.tasks or {}) do
table.insert(_data.tasks, table_to_task(t))
end
return _data
end
function M.save()
if not _data then
return
end
local path = config.get().data_path
ensure_dir(path)
local out = {
version = _data.version,
next_id = _data.next_id,
tasks = {},
}
for _, task in ipairs(_data.tasks) do
table.insert(out.tasks, task_to_table(task))
end
local encoded = vim.json.encode(out)
local f = io.open(path, 'w')
if not f then
error('pending.nvim: cannot write to ' .. path)
end
f:write(encoded)
f:close()
end
---@return pending.Data
function M.data()
if not _data then
M.load()
end
return _data --[[@as pending.Data]]
end
---@return pending.Task[]
function M.tasks()
return M.data().tasks
end
---@return pending.Task[]
function M.active_tasks()
local result = {}
for _, task in ipairs(M.tasks()) do
if task.status ~= 'deleted' then
table.insert(result, task)
end
end
return result
end
---@param id integer
---@return pending.Task?
function M.get(id)
for _, task in ipairs(M.tasks()) do
if task.id == id then
return task
end
end
return nil
end
---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, order?: integer, _extra?: table }
---@return pending.Task
function M.add(fields)
local data = M.data()
local now = timestamp()
local task = {
id = data.next_id,
description = fields.description,
status = fields.status or 'pending',
category = fields.category or config.get().default_category,
priority = fields.priority or 0,
due = fields.due,
entry = now,
modified = now,
['end'] = nil,
order = fields.order or 0,
_extra = fields._extra,
}
data.next_id = data.next_id + 1
table.insert(data.tasks, task)
return task
end
---@param id integer
---@param fields table<string, any>
---@return pending.Task?
function M.update(id, fields)
local task = M.get(id)
if not task then
return nil
end
local now = timestamp()
for k, v in pairs(fields) do
if k ~= 'id' and k ~= 'entry' then
task[k] = v
end
end
task.modified = now
if fields.status == 'done' or fields.status == 'deleted' then
task['end'] = task['end'] or now
end
return task
end
---@param id integer
---@return pending.Task?
function M.delete(id)
return M.update(id, { status = 'deleted', ['end'] = timestamp() })
end
---@param id integer
---@return integer?
function M.find_index(id)
for i, task in ipairs(M.tasks()) do
if task.id == id then
return i
end
end
return nil
end
---@param tasks pending.Task[]
function M.replace_tasks(tasks)
M.data().tasks = tasks
end
---@param id integer
function M.set_next_id(id)
M.data().next_id = id
end
function M.unload()
_data = nil
end
return M