refactor: organize tests and dry (#49)

* refactor(store): convert singleton to Store.new() factory

Problem: store.lua used module-level _data singleton, making
project-local stores impossible and creating hidden global state.

Solution: introduce Store metatable with all operations as instance
methods. M.new(path) constructs an instance; M.resolve_path()
searches upward for .pending.json and falls back to
config.get().data_path. Singleton module API is removed.

* refactor(diff): accept store instance as parameter

Problem: diff.apply called store singleton methods directly, coupling
it to global state and preventing use with project-local stores.

Solution: change signature to apply(lines, s, hidden_ids?) where s is
a pending.Store instance. All store operations now go through s.

* refactor(buffer): add set_store/store accessors, drop singleton dep

Problem: buffer.lua imported store directly and called singleton
methods, preventing it from working with per-project store instances.

Solution: add module-level _store, M.set_store(s), and M.store()
accessors. open() and render() use _store instead of the singleton.
init.lua will call buffer.set_store(s) before buffer.open().

* refactor(complete,health,sync,plugin): update callers to store instance API

Problem: complete.lua, health.lua, sync/gcal.lua, and plugin/pending.lua
all called singleton store methods directly.

Solution: complete.lua uses buffer.store() for category lookups;
health.lua uses store.new(store.resolve_path()) and reports the
resolved path; gcal.lua calls require('pending').store() for task
access; plugin tab-completion creates ephemeral store instances via
store.new(store.resolve_path()). Add 'init' to the subcommands list.

* feat(init): thread Store instance through init, add :Pending init

Problem: init.lua called singleton store methods throughout, and there
was no way to create a project-local .pending.json file.

Solution: add module-level _store and private get_store() that
lazy-constructs via store.new(store.resolve_path()). Add public
M.store() accessor used by specs and sync backends. M.open() calls
buffer.set_store(get_store()) before buffer.open(). All store
callsites converted to get_store():method(). goto_file() and
add_here() derive the data directory from get_store().path.

Add M.init() which creates .pending.json in cwd and dispatches from
M.command() as ':Pending init'.

* test: update all specs for Store instance API

Problem: every spec used the old singleton API (store.unload(),
store.load(), store.add(), etc.) and diff.apply(lines, hidden).

Solution: lower-level specs (store, diff, views, complete, file) use
s = store.new(path); s:load() directly. Higher-level specs (archive,
edit, filter, status, sync) reset package.loaded['pending'] in
before_each and use pending.store() to access the live instance.
diff.apply calls updated to diff.apply(lines, s, hidden_ids).

* docs(pending): document :Pending init and store resolution

Add *pending-store-resolution* section explaining upward .pending.json
discovery and fallback to the global data_path. Document :Pending init
under COMMANDS. Add a cross-reference from the data_path config field.

* ci: format

* ci: remove unused variable
This commit is contained in:
Barrett Ruth 2026-02-26 20:03:42 -05:00
parent dbd76d6759
commit 41bda24570
19 changed files with 819 additions and 703 deletions

View file

@ -356,6 +356,13 @@ COMMANDS *pending-commands*
Equivalent to the `U` buffer-local key (see |pending-mappings|). Up to 20
levels of undo are persisted across sessions.
*:Pending-init*
:Pending init
Create a project-local `.pending.json` file in the current working
directory. After creation, `:Pending` will use this file instead of the
global store (see |pending-store-resolution|). Errors if `.pending.json`
already exists in the current directory.
*:PendingTab*
:PendingTab
Open the task buffer in a new tab.
@ -614,9 +621,11 @@ All fields are optional. Unset fields use the defaults shown above.
*pending.Config*
Fields: ~
{data_path} (string)
Path to the JSON file where tasks are stored.
Path to the global JSON file where tasks are stored.
Default: `stdpath('data') .. '/pending/tasks.json'`.
The directory is created automatically on first save.
See |pending-store-resolution| for how the active
store is chosen at runtime.
{default_view} ('category'|'priority', default: 'category')
The view to use when the buffer is opened for the
@ -1060,10 +1069,31 @@ Checks performed: ~
- Discovers sync backends under `lua/pending/sync/` and runs each backend's
`health()` function if it exists (e.g. gcal checks for `curl` and `openssl`)
==============================================================================
STORE RESOLUTION *pending-store-resolution*
When pending.nvim opens the task buffer it resolves which store file to use:
1. Search upward from `vim.fn.getcwd()` for a file named `.pending.json`.
2. If found, use that file as the active store (project-local store).
3. If not found, fall back to `data_path` from |pending-config| (global
store).
This means placing a `.pending.json` file in a project root makes that
project use an isolated task list. Tasks in the project store are completely
separate from tasks in the global store; there is no aggregation.
To create a project-local store in the current directory: >vim
:Pending init
<
The `:checkhealth pending` report shows which store file is currently active.
==============================================================================
DATA FORMAT *pending-data*
Tasks are stored as JSON at `data_path`. The file is safe to edit by hand and
Tasks are stored as JSON at the active store path (see
|pending-store-resolution|). The file is safe to edit by hand and
is forward-compatible — unknown fields are preserved on every read/write cycle
via the `_extra` table.

View file

@ -1,10 +1,12 @@
local config = require('pending.config')
local store = require('pending.store')
local views = require('pending.views')
---@class pending.buffer
local M = {}
---@type pending.Store?
local _store = nil
---@type integer?
local task_bufnr = nil
---@type integer?
@ -41,6 +43,17 @@ function M.current_view_name()
return current_view
end
---@param s pending.Store?
---@return nil
function M.set_store(s)
_store = s
end
---@return pending.Store?
function M.store()
return _store
end
---@return string[]
function M.filter_predicates()
return _filter_predicates
@ -281,7 +294,7 @@ function M.render(bufnr)
current_view = current_view or config.get().default_view
local view_label = current_view == 'priority' and 'queue' or current_view
vim.api.nvim_buf_set_name(bufnr, 'pending://' .. view_label)
local all_tasks = store.active_tasks()
local all_tasks = _store and _store:active_tasks() or {}
local tasks = {}
for _, task in ipairs(all_tasks) do
if not _hidden_ids[task.id] then
@ -341,7 +354,9 @@ end
---@return integer bufnr
function M.open()
setup_highlights()
store.load()
if _store then
_store:load()
end
if task_winid and vim.api.nvim_win_is_valid(task_winid) then
vim.api.nvim_set_current_win(task_winid)

View file

@ -15,10 +15,13 @@ end
---@return string[]
local function get_categories()
local store = require('pending.store')
local s = require('pending.buffer').store()
if not s then
return {}
end
local seen = {}
local result = {}
for _, task in ipairs(store.active_tasks()) do
for _, task in ipairs(s:active_tasks()) do
local cat = task.category
if cat and not seen[cat] then
seen[cat] = true

View file

@ -1,6 +1,5 @@
local config = require('pending.config')
local parse = require('pending.parse')
local store = require('pending.store')
---@class pending.ParsedEntry
---@field type 'task'|'header'|'blank'
@ -72,12 +71,13 @@ function M.parse_buffer(lines)
end
---@param lines string[]
---@param s pending.Store
---@param hidden_ids? table<integer, true>
---@return nil
function M.apply(lines, hidden_ids)
function M.apply(lines, s, hidden_ids)
local parsed = M.parse_buffer(lines)
local now = timestamp()
local data = store.data()
local data = s:data()
local old_by_id = {}
for _, task in ipairs(data.tasks) do
@ -98,7 +98,7 @@ function M.apply(lines, hidden_ids)
if entry.id and old_by_id[entry.id] then
if seen_ids[entry.id] then
store.add({
s:add({
description = entry.description,
category = entry.category,
priority = entry.priority,
@ -166,7 +166,7 @@ function M.apply(lines, hidden_ids)
end
end
else
store.add({
s:add({
description = entry.description,
category = entry.category,
priority = entry.priority,
@ -188,7 +188,7 @@ function M.apply(lines, hidden_ids)
end
end
store.save()
s:save()
end
return M

View file

@ -12,36 +12,47 @@ function M.check()
local cfg = config.get()
vim.health.ok('Config loaded')
vim.health.info('Data path: ' .. cfg.data_path)
local data_dir = vim.fn.fnamemodify(cfg.data_path, ':h')
local store_ok, store = pcall(require, 'pending.store')
if not store_ok then
vim.health.error('Failed to load pending.store')
return
end
local resolved_path = store.resolve_path()
vim.health.info('Store path: ' .. resolved_path)
if resolved_path ~= cfg.data_path then
vim.health.info('(project-local store; global path: ' .. cfg.data_path .. ')')
end
local data_dir = vim.fn.fnamemodify(resolved_path, ':h')
if vim.fn.isdirectory(data_dir) == 1 then
vim.health.ok('Data directory exists: ' .. data_dir)
else
vim.health.warn('Data directory does not exist yet: ' .. data_dir)
end
if vim.fn.filereadable(cfg.data_path) == 1 then
local store_ok, store = pcall(require, 'pending.store')
if store_ok then
local load_ok, err = pcall(store.load)
if load_ok then
local tasks = store.tasks()
vim.health.ok('Data file loaded: ' .. #tasks .. ' tasks')
local recur = require('pending.recur')
local invalid_count = 0
for _, task in ipairs(tasks) do
if task.recur and not recur.validate(task.recur) then
invalid_count = invalid_count + 1
vim.health.warn('Task ' .. task.id .. ' has invalid recurrence spec: ' .. task.recur)
end
if vim.fn.filereadable(resolved_path) == 1 then
local s = store.new(resolved_path)
local load_ok, err = pcall(function()
s:load()
end)
if load_ok then
local tasks = s:tasks()
vim.health.ok('Data file loaded: ' .. #tasks .. ' tasks')
local recur = require('pending.recur')
local invalid_count = 0
for _, task in ipairs(tasks) do
if task.recur and not recur.validate(task.recur) then
invalid_count = invalid_count + 1
vim.health.warn('Task ' .. task.id .. ' has invalid recurrence spec: ' .. task.recur)
end
if invalid_count == 0 then
vim.health.ok('All recurrence specs are valid')
end
else
vim.health.error('Failed to load data file: ' .. tostring(err))
end
if invalid_count == 0 then
vim.health.ok('All recurrence specs are valid')
end
else
vim.health.error('Failed to load data file: ' .. tostring(err))
end
else
vim.health.info('No data file yet (will be created on first save)')

View file

@ -1,5 +1,4 @@
local buffer = require('pending.buffer')
local config = require('pending.config')
local diff = require('pending.diff')
local parse = require('pending.parse')
local store = require('pending.store')
@ -19,6 +18,22 @@ local UNDO_MAX = 20
---@type pending.Counts?
local _counts = nil
---@type pending.Store?
local _store = nil
---@return pending.Store
local function get_store()
if not _store then
_store = store.new(store.resolve_path())
end
return _store
end
---@return pending.Store
function M.store()
return get_store()
end
---@return nil
function M._recompute_counts()
local cfg = require('pending.config').get()
@ -30,7 +45,7 @@ function M._recompute_counts()
local next_due = nil ---@type string?
local today_str = os.date('%Y-%m-%d') --[[@as string]]
for _, task in ipairs(store.active_tasks()) do
for _, task in ipairs(get_store():active_tasks()) do
if task.status == 'pending' then
pending = pending + 1
if task.priority > 0 then
@ -63,14 +78,14 @@ end
---@return nil
local function _save_and_notify()
store.save()
get_store():save()
M._recompute_counts()
end
---@return pending.Counts
function M.counts()
if not _counts then
store.load()
get_store():load()
M._recompute_counts()
end
return _counts --[[@as pending.Counts]]
@ -138,6 +153,8 @@ end
---@return integer bufnr
function M.open()
local s = get_store()
buffer.set_store(s)
local bufnr = buffer.open()
M._setup_autocmds(bufnr)
M._setup_buf_mappings(bufnr)
@ -159,7 +176,7 @@ function M.filter(pred_str)
for word in pred_str:gmatch('%S+') do
table.insert(predicates, word)
end
local tasks = store.active_tasks()
local tasks = get_store():active_tasks()
local hidden = compute_hidden_ids(tasks, predicates)
buffer.set_filter(predicates, hidden)
local bufnr = buffer.bufnr()
@ -184,7 +201,7 @@ function M._setup_autocmds(bufnr)
buffer = bufnr,
callback = function()
if not vim.bo[bufnr].modified then
store.load()
get_store():load()
buffer.render(bufnr)
end
end,
@ -333,29 +350,31 @@ function M._on_write(bufnr)
elseif #buffer.filter_predicates() > 0 then
predicates = {}
end
local tasks = store.active_tasks()
local s = get_store()
local tasks = s:active_tasks()
local hidden = compute_hidden_ids(tasks, predicates)
buffer.set_filter(predicates, hidden)
local snapshot = store.snapshot()
local stack = store.undo_stack()
local snapshot = s:snapshot()
local stack = s:undo_stack()
table.insert(stack, snapshot)
if #stack > UNDO_MAX then
table.remove(stack, 1)
end
diff.apply(lines, hidden)
diff.apply(lines, s, hidden)
M._recompute_counts()
buffer.render(bufnr)
end
---@return nil
function M.undo_write()
local stack = store.undo_stack()
local s = get_store()
local stack = s:undo_stack()
if #stack == 0 then
vim.notify('Nothing to undo.', vim.log.levels.WARN)
return
end
local state = table.remove(stack)
store.replace_tasks(state)
s:replace_tasks(state)
_save_and_notify()
buffer.render(buffer.bufnr())
end
@ -375,18 +394,19 @@ function M.toggle_complete()
if not id then
return
end
local task = store.get(id)
local s = get_store()
local task = s:get(id)
if not task then
return
end
if task.status == 'done' then
store.update(id, { status = 'pending', ['end'] = vim.NIL })
s:update(id, { status = 'pending', ['end'] = vim.NIL })
else
if task.recur and task.due then
local recur = require('pending.recur')
local mode = task.recur_mode or 'scheduled'
local next_date = recur.next_due(task.due, task.recur, mode)
store.add({
s:add({
description = task.description,
category = task.category,
priority = task.priority,
@ -395,7 +415,7 @@ function M.toggle_complete()
recur_mode = task.recur_mode,
})
end
store.update(id, { status = 'done' })
s:update(id, { status = 'done' })
end
_save_and_notify()
buffer.render(bufnr)
@ -422,12 +442,13 @@ function M.toggle_priority()
if not id then
return
end
local task = store.get(id)
local s = get_store()
local task = s:get(id)
if not task then
return
end
local new_priority = task.priority > 0 and 0 or 1
store.update(id, { priority = new_priority })
s:update(id, { priority = new_priority })
_save_and_notify()
buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do
@ -470,7 +491,7 @@ function M.prompt_date()
return
end
end
store.update(id, { due = due })
get_store():update(id, { due = due })
_save_and_notify()
buffer.render(bufnr)
end)
@ -483,13 +504,14 @@ function M.add(text)
vim.notify('Usage: :Pending add <description>', vim.log.levels.ERROR)
return
end
store.load()
local s = get_store()
s:load()
local description, metadata = parse.command_add(text)
if not description or description == '' then
vim.notify('Pending must have a description.', vim.log.levels.ERROR)
return
end
store.add({
s:add({
description = description,
category = metadata.cat,
due = metadata.due,
@ -530,12 +552,13 @@ end
function M.archive(days)
days = days or 30
local cutoff = os.time() - (days * 86400)
local tasks = store.tasks()
local s = get_store()
local tasks = s:tasks()
local archived = 0
local kept = {}
for _, task in ipairs(tasks) do
if (task.status == 'done' or task.status == 'deleted') and task['end'] then
local y, mo, d, h, mi, s = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$')
local y, mo, d, h, mi, sec = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$')
if y then
local t = os.time({
year = tonumber(y) --[[@as integer]],
@ -543,7 +566,7 @@ function M.archive(days)
day = tonumber(d) --[[@as integer]],
hour = tonumber(h) --[[@as integer]],
min = tonumber(mi) --[[@as integer]],
sec = tonumber(s) --[[@as integer]],
sec = tonumber(sec) --[[@as integer]],
})
if t < cutoff then
archived = archived + 1
@ -554,7 +577,7 @@ function M.archive(days)
table.insert(kept, task)
::skip::
end
store.replace_tasks(kept)
s:replace_tasks(kept)
_save_and_notify()
vim.notify('Archived ' .. archived .. ' tasks.')
local bufnr = buffer.bufnr()
@ -578,7 +601,7 @@ function M.due()
and m.status ~= 'done'
and (parse.is_overdue(m.raw_due) or parse.is_today(m.raw_due))
then
local task = store.get(m.id or 0)
local task = get_store():get(m.id or 0)
local label = parse.is_overdue(m.raw_due) and '[OVERDUE] ' or '[DUE] '
table.insert(qf_items, {
bufnr = bufnr,
@ -589,8 +612,9 @@ function M.due()
end
end
else
store.load()
for _, task in ipairs(store.active_tasks()) do
local s = get_store()
s:load()
for _, task in ipairs(s:active_tasks()) do
if
task.status == 'pending'
and task.due
@ -712,8 +736,9 @@ function M.edit(id_str, rest)
return
end
store.load()
local task = store.get(id)
local s = get_store()
s:load()
local task = s:get(id)
if not task then
vim.notify('No task with ID ' .. id .. '.', vim.log.levels.ERROR)
return
@ -776,17 +801,17 @@ function M.edit(id_str, rest)
end
end
local snapshot = store.snapshot()
local stack = store.undo_stack()
local snapshot = s:snapshot()
local stack = s:undo_stack()
table.insert(stack, snapshot)
if #stack > UNDO_MAX then
table.remove(stack, 1)
end
store.update(id, updates)
s:update(id, updates)
if updates.file_clear then
local t = store.get(id)
local t = s:get(id)
if t and t._extra then
t._extra.file = nil
if next(t._extra) == nil then
@ -796,7 +821,7 @@ function M.edit(id_str, rest)
end
end
store.save()
s:save()
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
@ -819,7 +844,7 @@ function M.goto_file()
vim.notify('No task on this line', vim.log.levels.WARN)
return
end
local task = store.get(m.id)
local task = get_store():get(m.id)
if not task or not task._extra or not task._extra.file then
vim.notify('No file attached to this task', vim.log.levels.WARN)
return
@ -830,7 +855,7 @@ function M.goto_file()
vim.notify('Invalid file spec: ' .. file_spec, vim.log.levels.ERROR)
return
end
local data_dir = vim.fn.fnamemodify(config.get().data_path, ':h')
local data_dir = vim.fn.fnamemodify(get_store().path, ':h')
local abs_path = data_dir .. '/' .. rel_path
if vim.fn.filereadable(abs_path) == 0 then
vim.notify('File not found: ' .. abs_path, vim.log.levels.ERROR)
@ -854,7 +879,8 @@ function M.add_here()
return
end
local cur_lnum = vim.api.nvim_win_get_cursor(0)[1]
local data_dir = vim.fn.fnamemodify(config.get().data_path, ':h')
local s = get_store()
local data_dir = vim.fn.fnamemodify(s.path, ':h')
local abs_file = vim.fn.fnamemodify(cur_file, ':p')
local rel_file
if abs_file:sub(1, #data_dir + 1) == data_dir .. '/' then
@ -863,8 +889,8 @@ function M.add_here()
rel_file = abs_file
end
local file_spec = rel_file .. ':' .. cur_lnum
store.load()
local tasks = store.active_tasks()
s:load()
local tasks = s:active_tasks()
if #tasks == 0 then
vim.notify('No active tasks', vim.log.levels.INFO)
return
@ -885,11 +911,24 @@ function M.add_here()
task._extra = task._extra or {}
task._extra.file = file_spec
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
store.save()
s:save()
vim.notify('Attached ' .. file_spec .. ' to task ' .. task.id)
end)
end
---@return nil
function M.init()
local path = vim.fn.getcwd() .. '/.pending.json'
if vim.fn.filereadable(path) == 1 then
vim.notify('pending.nvim: .pending.json already exists', vim.log.levels.WARN)
return
end
local s = store.new(path)
s:load()
s:save()
vim.notify('pending.nvim: created ' .. path)
end
---@param args string
---@return nil
function M.command(args)
@ -915,6 +954,8 @@ function M.command(args)
M.filter(rest)
elseif cmd == 'undo' then
M.undo_write()
elseif cmd == 'init' then
M.init()
else
vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR)
end

View file

@ -21,14 +21,17 @@ local config = require('pending.config')
---@field tasks pending.Task[]
---@field undo pending.Task[][]
---@class pending.Store
---@field path string
---@field _data pending.Data?
local Store = {}
Store.__index = Store
---@class pending.store
local M = {}
local SUPPORTED_VERSION = 1
---@type pending.Data?
local _data = nil
---@return pending.Data
local function empty_data()
return {
@ -137,18 +140,18 @@ local function table_to_task(t)
end
---@return pending.Data
function M.load()
local path = config.get().data_path
function Store:load()
local path = self.path
local f = io.open(path, 'r')
if not f then
_data = empty_data()
return _data
self._data = empty_data()
return self._data
end
local content = f:read('*a')
f:close()
if content == '' then
_data = empty_data()
return _data
self._data = empty_data()
return self._data
end
local ok, decoded = pcall(vim.json.decode, content)
if not ok then
@ -163,14 +166,14 @@ function M.load()
.. '. Please update the plugin.'
)
end
_data = {
self._data = {
version = decoded.version or SUPPORTED_VERSION,
next_id = decoded.next_id or 1,
tasks = {},
undo = {},
}
for _, t in ipairs(decoded.tasks or {}) do
table.insert(_data.tasks, table_to_task(t))
table.insert(self._data.tasks, table_to_task(t))
end
for _, snapshot in ipairs(decoded.undo or {}) do
if type(snapshot) == 'table' then
@ -178,29 +181,29 @@ function M.load()
for _, raw in ipairs(snapshot) do
table.insert(tasks, table_to_task(raw))
end
table.insert(_data.undo, tasks)
table.insert(self._data.undo, tasks)
end
end
return _data
return self._data
end
---@return nil
function M.save()
if not _data then
function Store:save()
if not self._data then
return
end
local path = config.get().data_path
local path = self.path
ensure_dir(path)
local out = {
version = _data.version,
next_id = _data.next_id,
version = self._data.version,
next_id = self._data.next_id,
tasks = {},
undo = {},
}
for _, task in ipairs(_data.tasks) do
for _, task in ipairs(self._data.tasks) do
table.insert(out.tasks, task_to_table(task))
end
for _, snapshot in ipairs(_data.undo) do
for _, snapshot in ipairs(self._data.undo) do
local serialized = {}
for _, task in ipairs(snapshot) do
table.insert(serialized, task_to_table(task))
@ -223,22 +226,22 @@ function M.save()
end
---@return pending.Data
function M.data()
if not _data then
M.load()
function Store:data()
if not self._data then
self:load()
end
return _data --[[@as pending.Data]]
return self._data --[[@as pending.Data]]
end
---@return pending.Task[]
function M.tasks()
return M.data().tasks
function Store:tasks()
return self:data().tasks
end
---@return pending.Task[]
function M.active_tasks()
function Store:active_tasks()
local result = {}
for _, task in ipairs(M.tasks()) do
for _, task in ipairs(self:tasks()) do
if task.status ~= 'deleted' then
table.insert(result, task)
end
@ -248,8 +251,8 @@ end
---@param id integer
---@return pending.Task?
function M.get(id)
for _, task in ipairs(M.tasks()) do
function Store:get(id)
for _, task in ipairs(self:tasks()) do
if task.id == id then
return task
end
@ -259,8 +262,8 @@ end
---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, recur?: string, recur_mode?: string, order?: integer, _extra?: table }
---@return pending.Task
function M.add(fields)
local data = M.data()
function Store:add(fields)
local data = self:data()
local now = timestamp()
local task = {
id = data.next_id,
@ -285,8 +288,8 @@ end
---@param id integer
---@param fields table<string, any>
---@return pending.Task?
function M.update(id, fields)
local task = M.get(id)
function Store:update(id, fields)
local task = self:get(id)
if not task then
return nil
end
@ -309,14 +312,14 @@ end
---@param id integer
---@return pending.Task?
function M.delete(id)
return M.update(id, { status = 'deleted', ['end'] = timestamp() })
function Store:delete(id)
return self: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
function Store:find_index(id)
for i, task in ipairs(self:tasks()) do
if task.id == id then
return i
end
@ -326,14 +329,14 @@ end
---@param tasks pending.Task[]
---@return nil
function M.replace_tasks(tasks)
M.data().tasks = tasks
function Store:replace_tasks(tasks)
self:data().tasks = tasks
end
---@return pending.Task[]
function M.snapshot()
function Store:snapshot()
local result = {}
for _, task in ipairs(M.active_tasks()) do
for _, task in ipairs(self:active_tasks()) do
local copy = {}
for k, v in pairs(task) do
if k ~= '_extra' then
@ -352,25 +355,44 @@ function M.snapshot()
end
---@return pending.Task[][]
function M.undo_stack()
return M.data().undo
function Store:undo_stack()
return self:data().undo
end
---@param stack pending.Task[][]
---@return nil
function M.set_undo_stack(stack)
M.data().undo = stack
function Store:set_undo_stack(stack)
self:data().undo = stack
end
---@param id integer
---@return nil
function M.set_next_id(id)
M.data().next_id = id
function Store:set_next_id(id)
self:data().next_id = id
end
---@return nil
function M.unload()
_data = nil
function Store:unload()
self._data = nil
end
---@param path string
---@return pending.Store
function M.new(path)
return setmetatable({ path = path, _data = nil }, Store)
end
---@return string
function M.resolve_path()
local results = vim.fs.find('.pending.json', {
upward = true,
path = vim.fn.getcwd(),
type = 'file',
})
if results and #results > 0 then
return results[1]
end
return config.get().data_path
end
return M

View file

@ -1,5 +1,4 @@
local config = require('pending.config')
local store = require('pending.store')
local M = {}
@ -458,7 +457,7 @@ function M.sync()
return
end
local tasks = store.tasks()
local tasks = require('pending').store():tasks()
local created, updated, deleted = 0, 0, 0
for _, task in ipairs(tasks) do
@ -504,7 +503,7 @@ function M.sync()
end
end
store.save()
require('pending').store():save()
require('pending')._recompute_counts()
vim.notify(
string.format(

View file

@ -100,9 +100,10 @@ local function complete_edit(arg_lead, cmd_line)
local trailing_space = after_edit:match('%s$')
if #parts == 0 or (#parts == 1 and not trailing_space) then
local store = require('pending.store')
store.load()
local s = store.new(store.resolve_path())
s:load()
local ids = {}
for _, task in ipairs(store.active_tasks()) do
for _, task in ipairs(s:active_tasks()) do
table.insert(ids, tostring(task.id))
end
return filter_candidates(arg_lead, ids)
@ -138,10 +139,11 @@ local function complete_edit(arg_lead, cmd_line)
if cat_prefix then
local after_colon = arg_lead:sub(#cat_prefix + 1)
local store = require('pending.store')
store.load()
local s = store.new(store.resolve_path())
s:load()
local seen = {}
local cats = {}
for _, task in ipairs(store.active_tasks()) do
for _, task in ipairs(s:active_tasks()) do
if task.category and not seen[task.category] then
seen[task.category] = true
table.insert(cats, task.category)
@ -166,7 +168,7 @@ end, {
bar = true,
nargs = '*',
complete = function(arg_lead, cmd_line)
local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'sync', 'undo' }
local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'init', 'sync', 'undo' }
if not cmd_line:match('^Pending%s+%S') then
return filter_candidates(arg_lead, subcmds)
end
@ -178,9 +180,10 @@ end, {
end
local candidates = { 'clear', 'overdue', 'today', 'priority' }
local store = require('pending.store')
store.load()
local s = store.new(store.resolve_path())
s:load()
local seen = {}
for _, task in ipairs(store.active_tasks()) do
for _, task in ipairs(s:active_tasks()) do
if task.category and not seen[task.category] then
seen[task.category] = true
table.insert(candidates, 'cat:' .. task.category)

View file

@ -1,87 +1,96 @@
require('spec.helpers')
local config = require('pending.config')
local store = require('pending.store')
describe('archive', function()
local tmpdir
local pending = require('pending')
local pending
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
store.load()
package.loaded['pending'] = nil
pending = require('pending')
pending.store():load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
package.loaded['pending'] = nil
end)
it('removes done tasks completed more than 30 days ago', function()
local t = store.add({ description = 'Old done task' })
store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
local s = pending.store()
local t = s:add({ description = 'Old done task' })
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive()
assert.are.equal(0, #store.active_tasks())
assert.are.equal(0, #s:active_tasks())
end)
it('keeps done tasks completed fewer than 30 days ago', function()
local s = pending.store()
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
local t = store.add({ description = 'Recent done task' })
store.update(t.id, { status = 'done', ['end'] = recent_end })
local t = s:add({ description = 'Recent done task' })
s:update(t.id, { status = 'done', ['end'] = recent_end })
pending.archive()
local active = store.active_tasks()
local active = s:active_tasks()
assert.are.equal(1, #active)
assert.are.equal('Recent done task', active[1].description)
end)
it('respects a custom day count', function()
local s = pending.store()
local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400))
local t = store.add({ description = 'Old for 7 days' })
store.update(t.id, { status = 'done', ['end'] = eight_days_ago })
local t = s:add({ description = 'Old for 7 days' })
s:update(t.id, { status = 'done', ['end'] = eight_days_ago })
pending.archive(7)
assert.are.equal(0, #store.active_tasks())
assert.are.equal(0, #s:active_tasks())
end)
it('keeps tasks within the custom day cutoff', function()
local s = pending.store()
local five_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
local t = store.add({ description = 'Recent for 7 days' })
store.update(t.id, { status = 'done', ['end'] = five_days_ago })
local t = s:add({ description = 'Recent for 7 days' })
s:update(t.id, { status = 'done', ['end'] = five_days_ago })
pending.archive(7)
local active = store.active_tasks()
local active = s:active_tasks()
assert.are.equal(1, #active)
end)
it('never archives pending tasks regardless of age', function()
store.add({ description = 'Still pending' })
local s = pending.store()
s:add({ description = 'Still pending' })
pending.archive()
local active = store.active_tasks()
local active = s:active_tasks()
assert.are.equal(1, #active)
assert.are.equal('pending', active[1].status)
end)
it('removes deleted tasks past the cutoff', function()
local t = store.add({ description = 'Old deleted task' })
store.update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' })
local s = pending.store()
local t = s:add({ description = 'Old deleted task' })
s:update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive()
local all = store.tasks()
local all = s:tasks()
assert.are.equal(0, #all)
end)
it('keeps deleted tasks within the cutoff', function()
local s = pending.store()
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
local t = store.add({ description = 'Recent deleted' })
store.update(t.id, { status = 'deleted', ['end'] = recent_end })
local t = s:add({ description = 'Recent deleted' })
s:update(t.id, { status = 'deleted', ['end'] = recent_end })
pending.archive()
local all = store.tasks()
local all = s:tasks()
assert.are.equal(1, #all)
end)
it('reports the correct count in vim.notify', function()
local s = pending.store()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, ...)
@ -89,11 +98,11 @@ describe('archive', function()
return orig_notify(msg, ...)
end
local t1 = store.add({ description = 'Old 1' })
local t2 = store.add({ description = 'Old 2' })
store.add({ description = 'Keep' })
store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
store.update(t2.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
local t1 = s:add({ description = 'Old 1' })
local t2 = s:add({ description = 'Old 2' })
s:add({ description = 'Keep' })
s:update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
s:update(t2.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive()
@ -110,16 +119,17 @@ describe('archive', function()
end)
it('leaves only kept tasks in store.active_tasks after archive', function()
local t1 = store.add({ description = 'Old done' })
store.add({ description = 'Keep pending' })
local s = pending.store()
local t1 = s:add({ description = 'Old done' })
s:add({ description = 'Keep pending' })
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
local t3 = store.add({ description = 'Keep recent done' })
store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
store.update(t3.id, { status = 'done', ['end'] = recent_end })
local t3 = s:add({ description = 'Keep recent done' })
s:update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
s:update(t3.id, { status = 'done', ['end'] = recent_end })
pending.archive()
local active = store.active_tasks()
local active = s:active_tasks()
assert.are.equal(2, #active)
local descs = {}
for _, task in ipairs(active) do
@ -130,11 +140,11 @@ describe('archive', function()
end)
it('persists archived tasks to disk after unload/reload', function()
local t = store.add({ description = 'Archived task' })
store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
local s = pending.store()
local t = s:add({ description = 'Archived task' })
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive()
store.unload()
store.load()
assert.are.equal(0, #store.active_tasks())
s:load()
assert.are.equal(0, #s:active_tasks())
end)
end)

View file

@ -1,25 +1,27 @@
require('spec.helpers')
local buffer = require('pending.buffer')
local config = require('pending.config')
local store = require('pending.store')
describe('complete', function()
local tmpdir
local s
local complete = require('pending.complete')
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
store.load()
s = store.new(tmpdir .. '/tasks.json')
s:load()
buffer.set_store(s)
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
buffer.set_store(nil)
end)
describe('findstart', function()
@ -66,9 +68,9 @@ describe('complete', function()
describe('completions', function()
it('returns existing categories for cat:', function()
store.add({ description = 'A', category = 'Work' })
store.add({ description = 'B', category = 'Home' })
store.add({ description = 'C', category = 'Work' })
s:add({ description = 'A', category = 'Work' })
s:add({ description = 'B', category = 'Home' })
s:add({ description = 'C', category = 'Work' })
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task cat: x' })
vim.api.nvim_set_current_buf(bufnr)
@ -85,8 +87,8 @@ describe('complete', function()
end)
it('filters categories by base', function()
store.add({ description = 'A', category = 'Work' })
store.add({ description = 'B', category = 'Home' })
s:add({ description = 'A', category = 'Work' })
s:add({ description = 'B', category = 'Home' })
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task cat:W' })
vim.api.nvim_set_current_buf(bufnr)

View file

@ -1,25 +1,21 @@
require('spec.helpers')
local config = require('pending.config')
local store = require('pending.store')
describe('diff', function()
local tmpdir
local s
local diff = require('pending.diff')
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
store.load()
s = store.new(tmpdir .. '/tasks.json')
s:load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
end)
describe('parse_buffer', function()
@ -107,121 +103,112 @@ describe('diff', function()
'- [ ] First task',
'- [ ] Second task',
}
diff.apply(lines)
store.unload()
store.load()
local tasks = store.active_tasks()
diff.apply(lines, s)
s:load()
local tasks = s:active_tasks()
assert.are.equal(2, #tasks)
assert.are.equal('First task', tasks[1].description)
assert.are.equal('Second task', tasks[2].description)
end)
it('deletes tasks removed from buffer', function()
store.add({ description = 'Keep me' })
store.add({ description = 'Delete me' })
store.save()
s:add({ description = 'Keep me' })
s:add({ description = 'Delete me' })
s:save()
local lines = {
'## Inbox',
'/1/- [ ] Keep me',
}
diff.apply(lines)
store.unload()
store.load()
local active = store.active_tasks()
diff.apply(lines, s)
s:load()
local active = s:active_tasks()
assert.are.equal(1, #active)
assert.are.equal('Keep me', active[1].description)
local deleted = store.get(2)
local deleted = s:get(2)
assert.are.equal('deleted', deleted.status)
end)
it('updates modified tasks', function()
store.add({ description = 'Original' })
store.save()
s:add({ description = 'Original' })
s:save()
local lines = {
'## Inbox',
'/1/- [ ] Renamed',
}
diff.apply(lines)
store.unload()
store.load()
local task = store.get(1)
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('Renamed', task.description)
end)
it('updates modified when description is renamed', function()
local t = store.add({ description = 'Original', category = 'Inbox' })
local t = s:add({ description = 'Original', category = 'Inbox' })
t.modified = '2020-01-01T00:00:00Z'
store.save()
s:save()
local lines = {
'## Inbox',
'/1/- [ ] Renamed',
}
diff.apply(lines)
store.unload()
store.load()
local task = store.get(1)
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('Renamed', task.description)
assert.is_not.equal('2020-01-01T00:00:00Z', task.modified)
end)
it('handles duplicate ids as copies', function()
store.add({ description = 'Original' })
store.save()
s:add({ description = 'Original' })
s:save()
local lines = {
'## Inbox',
'/1/- [ ] Original',
'/1/- [ ] Copy of original',
}
diff.apply(lines)
store.unload()
store.load()
local tasks = store.active_tasks()
diff.apply(lines, s)
s:load()
local tasks = s:active_tasks()
assert.are.equal(2, #tasks)
end)
it('moves tasks between categories', function()
store.add({ description = 'Moving task', category = 'Inbox' })
store.save()
s:add({ description = 'Moving task', category = 'Inbox' })
s:save()
local lines = {
'## Work',
'/1/- [ ] Moving task',
}
diff.apply(lines)
store.unload()
store.load()
local task = store.get(1)
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('Work', task.category)
end)
it('does not update modified when task is unchanged', function()
store.add({ description = 'Stable task', category = 'Inbox' })
store.save()
s:add({ description = 'Stable task', category = 'Inbox' })
s:save()
local lines = {
'## Inbox',
'/1/- [ ] Stable task',
}
diff.apply(lines)
store.unload()
store.load()
local modified_after_first = store.get(1).modified
diff.apply(lines)
store.unload()
store.load()
local task = store.get(1)
diff.apply(lines, s)
s:load()
local modified_after_first = s:get(1).modified
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal(modified_after_first, task.modified)
end)
it('clears due when removed from buffer line', function()
store.add({ description = 'Pay bill', due = '2026-03-15' })
store.save()
s:add({ description = 'Pay bill', due = '2026-03-15' })
s:save()
local lines = {
'## Inbox',
'/1/- [ ] Pay bill',
}
diff.apply(lines)
store.unload()
store.load()
local task = store.get(1)
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.is_nil(task.due)
end)
@ -230,39 +217,36 @@ describe('diff', function()
'## Inbox',
'- [ ] Take out trash rec:weekly',
}
diff.apply(lines)
store.unload()
store.load()
local tasks = store.active_tasks()
diff.apply(lines, s)
s:load()
local tasks = s:active_tasks()
assert.are.equal(1, #tasks)
assert.are.equal('weekly', tasks[1].recur)
end)
it('updates recur field when changed inline', function()
store.add({ description = 'Task', recur = 'daily' })
store.save()
s:add({ description = 'Task', recur = 'daily' })
s:save()
local lines = {
'## Todo',
'/1/- [ ] Task rec:weekly',
}
diff.apply(lines)
store.unload()
store.load()
local task = store.get(1)
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('weekly', task.recur)
end)
it('clears recur when token removed from line', function()
store.add({ description = 'Task', recur = 'daily' })
store.save()
s:add({ description = 'Task', recur = 'daily' })
s:save()
local lines = {
'## Todo',
'/1/- [ ] Task',
}
diff.apply(lines)
store.unload()
store.load()
local task = store.get(1)
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.is_nil(task.recur)
end)
@ -271,25 +255,23 @@ describe('diff', function()
'## Inbox',
'- [ ] Water plants rec:!weekly',
}
diff.apply(lines)
store.unload()
store.load()
local tasks = store.active_tasks()
diff.apply(lines, s)
s:load()
local tasks = s:active_tasks()
assert.are.equal('weekly', tasks[1].recur)
assert.are.equal('completion', tasks[1].recur_mode)
end)
it('clears priority when [N] is removed from buffer line', function()
store.add({ description = 'Task name', priority = 1 })
store.save()
s:add({ description = 'Task name', priority = 1 })
s:save()
local lines = {
'## Inbox',
'/1/- [ ] Task name',
}
diff.apply(lines)
store.unload()
store.load()
local task = store.get(1)
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal(0, task.priority)
end)
end)

View file

@ -1,32 +1,34 @@
require('spec.helpers')
local config = require('pending.config')
local store = require('pending.store')
describe('edit', function()
local tmpdir
local pending = require('pending')
local pending
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
store.load()
package.loaded['pending'] = nil
pending = require('pending')
pending.store():load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
package.loaded['pending'] = nil
end)
it('sets due date with resolve_date vocabulary', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'due:tomorrow')
local updated = store.get(t.id)
local updated = s:get(t.id)
local today = os.date('*t') --[[@as osdate]]
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }))
@ -34,111 +36,123 @@ describe('edit', function()
end)
it('sets due date with literal YYYY-MM-DD', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'due:2026-06-15')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.are.equal('2026-06-15', updated.due)
end)
it('sets category', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'cat:Work')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.are.equal('Work', updated.category)
end)
it('adds priority', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), '+!')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.are.equal(1, updated.priority)
end)
it('removes priority', function()
local t = store.add({ description = 'Task one', priority = 1 })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one', priority = 1 })
s:save()
pending.edit(tostring(t.id), '-!')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.are.equal(0, updated.priority)
end)
it('removes due date', function()
local t = store.add({ description = 'Task one', due = '2026-06-15' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one', due = '2026-06-15' })
s:save()
pending.edit(tostring(t.id), '-due')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.is_nil(updated.due)
end)
it('removes category', function()
local t = store.add({ description = 'Task one', category = 'Work' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one', category = 'Work' })
s:save()
pending.edit(tostring(t.id), '-cat')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.is_nil(updated.category)
end)
it('sets recurrence', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'rec:weekly')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.are.equal('weekly', updated.recur)
assert.is_nil(updated.recur_mode)
end)
it('sets completion-based recurrence', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'rec:!daily')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.are.equal('daily', updated.recur)
assert.are.equal('completion', updated.recur_mode)
end)
it('removes recurrence', function()
local t = store.add({ description = 'Task one', recur = 'weekly', recur_mode = 'scheduled' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one', recur = 'weekly', recur_mode = 'scheduled' })
s:save()
pending.edit(tostring(t.id), '-rec')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.is_nil(updated.recur)
assert.is_nil(updated.recur_mode)
end)
it('applies multiple operations at once', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'due:today cat:Errands +!')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.are.equal(os.date('%Y-%m-%d'), updated.due)
assert.are.equal('Errands', updated.category)
assert.are.equal(1, updated.priority)
end)
it('pushes to undo stack', function()
local t = store.add({ description = 'Task one' })
store.save()
local stack_before = #store.undo_stack()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
local stack_before = #s:undo_stack()
pending.edit(tostring(t.id), 'cat:Work')
assert.are.equal(stack_before + 1, #store.undo_stack())
assert.are.equal(stack_before + 1, #s:undo_stack())
end)
it('persists changes to disk', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'cat:Work')
store.unload()
store.load()
local updated = store.get(t.id)
s:load()
local updated = s:get(t.id)
assert.are.equal('Work', updated.category)
end)
it('errors on unknown task ID', function()
store.add({ description = 'Task one' })
store.save()
local s = pending.store()
s:add({ description = 'Task one' })
s:save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
@ -152,8 +166,9 @@ describe('edit', function()
end)
it('errors on invalid date', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
@ -167,8 +182,9 @@ describe('edit', function()
end)
it('errors on unknown operation token', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
@ -182,8 +198,9 @@ describe('edit', function()
end)
it('errors on invalid recurrence pattern', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
@ -197,8 +214,9 @@ describe('edit', function()
end)
it('errors when no operations given', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
@ -238,8 +256,9 @@ describe('edit', function()
end)
it('shows feedback message on success', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
@ -255,12 +274,14 @@ describe('edit', function()
it('respects custom date_syntax', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', date_syntax = 'by' }
config.reset()
store.unload()
store.load()
local t = store.add({ description = 'Task one' })
store.save()
package.loaded['pending'] = nil
pending = require('pending')
local s = pending.store()
s:load()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'by:tomorrow')
local updated = store.get(t.id)
local updated = s:get(t.id)
local today = os.date('*t') --[[@as osdate]]
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }))
@ -270,32 +291,36 @@ describe('edit', function()
it('respects custom recur_syntax', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', recur_syntax = 'repeat' }
config.reset()
store.unload()
store.load()
local t = store.add({ description = 'Task one' })
store.save()
package.loaded['pending'] = nil
pending = require('pending')
local s = pending.store()
s:load()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'repeat:weekly')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.are.equal('weekly', updated.recur)
end)
it('does not modify store on error', function()
local t = store.add({ description = 'Task one', category = 'Original' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one', category = 'Original' })
s:save()
local orig_notify = vim.notify
vim.notify = function() end
pending.edit(tostring(t.id), 'due:notadate')
vim.notify = orig_notify
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.are.equal('Original', updated.category)
assert.is_nil(updated.due)
end)
it('sets due date with datetime format', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'due:tomorrow@14:00')
local updated = store.get(t.id)
local updated = s:get(t.id)
local today = os.date('*t') --[[@as osdate]]
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }))

View file

@ -8,21 +8,25 @@ local views = require('pending.views')
describe('file token', function()
local tmpdir
local s
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
store.load()
package.loaded['pending'] = nil
package.loaded['pending.buffer'] = nil
s = store.new(tmpdir .. '/tasks.json')
s:load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
store.unload()
package.loaded['pending'] = nil
package.loaded['pending.buffer'] = nil
end)
describe('parse.body', function()
@ -78,89 +82,88 @@ describe('file token', function()
describe('diff reconciliation', function()
it('stores file field in _extra on write', function()
local t = store.add({ description = 'Task one' })
store.save()
local t = s:add({ description = 'Task one' })
s:save()
local lines = {
'/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42',
}
diff.apply(lines)
local updated = store.get(t.id)
diff.apply(lines, s)
local updated = s:get(t.id)
assert.is_not_nil(updated._extra)
assert.are.equal('src/auth.lua:42', updated._extra.file)
end)
it('updates file field when token changes', function()
local t = store.add({ description = 'Task one', _extra = { file = 'old.lua:1' } })
store.save()
local t = s:add({ description = 'Task one', _extra = { file = 'old.lua:1' } })
s:save()
local lines = {
'/' .. t.id .. '/- [ ] Task one file:new.lua:99',
}
diff.apply(lines)
local updated = store.get(t.id)
diff.apply(lines, s)
local updated = s:get(t.id)
assert.are.equal('new.lua:99', updated._extra.file)
end)
it('clears file field when token is removed from line', function()
local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } })
store.save()
local t = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } })
s:save()
local lines = {
'/' .. t.id .. '/- [ ] Task one',
}
diff.apply(lines)
local updated = store.get(t.id)
diff.apply(lines, s)
local updated = s:get(t.id)
assert.is_nil(updated._extra)
end)
it('preserves other _extra fields when file is cleared', function()
local t = store.add({
local t = s:add({
description = 'Task one',
_extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc123' },
})
store.save()
s:save()
local lines = {
'/' .. t.id .. '/- [ ] Task one',
}
diff.apply(lines)
local updated = store.get(t.id)
diff.apply(lines, s)
local updated = s:get(t.id)
assert.is_not_nil(updated._extra)
assert.is_nil(updated._extra.file)
assert.are.equal('abc123', updated._extra._gcal_event_id)
end)
it('round-trips file field through JSON', function()
local t = store.add({ description = 'Task one' })
store.save()
local t = s:add({ description = 'Task one' })
s:save()
local lines = {
'/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42',
}
diff.apply(lines)
store.unload()
store.load()
local loaded = store.get(t.id)
diff.apply(lines, s)
s:load()
local loaded = s:get(t.id)
assert.is_not_nil(loaded._extra)
assert.are.equal('src/auth.lua:42', loaded._extra.file)
end)
it('accepts optional hidden_ids parameter without error', function()
local t = store.add({ description = 'Task one' })
store.save()
local t = s:add({ description = 'Task one' })
s:save()
local lines = {
'/' .. t.id .. '/- [ ] Task one',
}
assert.has_no_error(function()
diff.apply(lines, {})
diff.apply(lines, s, {})
end)
end)
end)
describe('LineMeta', function()
it('category_view populates file field in LineMeta', function()
local t = store.add({
local t = s:add({
description = 'Task one',
_extra = { file = 'src/auth.lua:42' },
})
store.save()
local tasks = store.active_tasks()
s:save()
local tasks = s:active_tasks()
local _, meta = views.category_view(tasks)
local task_meta = nil
for _, m in ipairs(meta) do
@ -174,12 +177,12 @@ describe('file token', function()
end)
it('priority_view populates file field in LineMeta', function()
local t = store.add({
local t = s:add({
description = 'Task one',
_extra = { file = 'src/auth.lua:42' },
})
store.save()
local tasks = store.active_tasks()
s:save()
local tasks = s:active_tasks()
local _, meta = views.priority_view(tasks)
local task_meta = nil
for _, m in ipairs(meta) do
@ -193,9 +196,9 @@ describe('file token', function()
end)
it('file field is nil in LineMeta when task has no file', function()
local t = store.add({ description = 'Task one' })
store.save()
local tasks = store.active_tasks()
local t = s:add({ description = 'Task one' })
s:save()
local tasks = s:active_tasks()
local _, meta = views.category_view(tasks)
local task_meta = nil
for _, m in ipairs(meta) do
@ -212,17 +215,18 @@ describe('file token', function()
describe(':Pending edit -file', function()
it('clears file reference from task', function()
local pending = require('pending')
local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } })
store.save()
local t = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } })
s:save()
pending.edit(tostring(t.id), '-file')
local updated = store.get(t.id)
s:load()
local updated = s:get(t.id)
assert.is_nil(updated._extra)
end)
it('shows feedback when file reference is removed', function()
local pending = require('pending')
local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } })
store.save()
local t = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } })
s:save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
@ -236,8 +240,8 @@ describe('file token', function()
it('does not error when task has no file', function()
local pending = require('pending')
local t = store.add({ description = 'Task one' })
store.save()
local t = s:add({ description = 'Task one' })
s:save()
assert.has_no_error(function()
pending.edit(tostring(t.id), '-file')
end)
@ -245,13 +249,14 @@ describe('file token', function()
it('preserves other _extra fields when -file is used', function()
local pending = require('pending')
local t = store.add({
local t = s:add({
description = 'Task one',
_extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc' },
})
store.save()
s:save()
pending.edit(tostring(t.id), '-file')
local updated = store.get(t.id)
s:load()
local updated = s:get(t.id)
assert.is_not_nil(updated._extra)
assert.is_nil(updated._extra.file)
assert.are.equal('abc', updated._extra._gcal_event_id)
@ -263,9 +268,10 @@ describe('file token', function()
local pending = require('pending')
local buffer = require('pending.buffer')
local t = store.add({ description = 'Task one' })
store.save()
local t = s:add({ description = 'Task one' })
s:save()
buffer.set_store(s)
local bufnr = buffer.open()
vim.bo[bufnr].filetype = 'pending'
vim.api.nvim_set_current_buf(bufnr)
@ -306,12 +312,13 @@ describe('file token', function()
local pending = require('pending')
local buffer = require('pending.buffer')
local t = store.add({
local t = s:add({
description = 'Task one',
_extra = { file = 'nonexistent/path.lua:1' },
})
store.save()
s:save()
buffer.set_store(s)
local bufnr = buffer.open()
vim.bo[bufnr].filetype = 'pending'
vim.api.nvim_set_current_buf(bufnr)

View file

@ -2,7 +2,6 @@ require('spec.helpers')
local config = require('pending.config')
local diff = require('pending.diff')
local store = require('pending.store')
describe('filter', function()
local tmpdir
@ -14,32 +13,31 @@ describe('filter', function()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
package.loaded['pending'] = nil
package.loaded['pending.buffer'] = nil
pending = require('pending')
buffer = require('pending.buffer')
buffer.set_filter({}, {})
pending.store():load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
store.unload()
package.loaded['pending'] = nil
package.loaded['pending.buffer'] = nil
end)
describe('filter predicates', function()
it('cat: hides tasks with non-matching category', function()
store.load()
store.add({ description = 'Work task', category = 'Work' })
store.add({ description = 'Home task', category = 'Home' })
store.save()
local s = pending.store()
s:add({ description = 'Work task', category = 'Work' })
s:add({ description = 'Home task', category = 'Home' })
s:save()
pending.filter('cat:Work')
local hidden = buffer.hidden_ids()
local tasks = store.active_tasks()
local tasks = s:active_tasks()
local work_task = nil
local home_task = nil
for _, t in ipairs(tasks) do
@ -57,13 +55,13 @@ describe('filter', function()
end)
it('cat: hides tasks with no category (default category)', function()
store.load()
store.add({ description = 'Work task', category = 'Work' })
store.add({ description = 'Inbox task' })
store.save()
local s = pending.store()
s:add({ description = 'Work task', category = 'Work' })
s:add({ description = 'Inbox task' })
s:save()
pending.filter('cat:Work')
local hidden = buffer.hidden_ids()
local tasks = store.active_tasks()
local tasks = s:active_tasks()
local inbox_task = nil
for _, t in ipairs(tasks) do
if t.category ~= 'Work' then
@ -75,14 +73,14 @@ describe('filter', function()
end)
it('overdue hides non-overdue tasks', function()
store.load()
store.add({ description = 'Old task', due = '2020-01-01' })
store.add({ description = 'Future task', due = '2099-01-01' })
store.add({ description = 'No due task' })
store.save()
local s = pending.store()
s:add({ description = 'Old task', due = '2020-01-01' })
s:add({ description = 'Future task', due = '2099-01-01' })
s:add({ description = 'No due task' })
s:save()
pending.filter('overdue')
local hidden = buffer.hidden_ids()
local tasks = store.active_tasks()
local tasks = s:active_tasks()
local overdue_task, future_task, nodue_task
for _, t in ipairs(tasks) do
if t.due == '2020-01-01' then
@ -101,15 +99,15 @@ describe('filter', function()
end)
it('today hides non-today tasks', function()
store.load()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Today task', due = today })
store.add({ description = 'Old task', due = '2020-01-01' })
store.add({ description = 'Future task', due = '2099-01-01' })
store.save()
s:add({ description = 'Today task', due = today })
s:add({ description = 'Old task', due = '2020-01-01' })
s:add({ description = 'Future task', due = '2099-01-01' })
s:save()
pending.filter('today')
local hidden = buffer.hidden_ids()
local tasks = store.active_tasks()
local tasks = s:active_tasks()
local today_task, old_task, future_task
for _, t in ipairs(tasks) do
if t.due == today then
@ -128,13 +126,13 @@ describe('filter', function()
end)
it('priority hides non-priority tasks', function()
store.load()
store.add({ description = 'Important', priority = 1 })
store.add({ description = 'Normal' })
store.save()
local s = pending.store()
s:add({ description = 'Important', priority = 1 })
s:add({ description = 'Normal' })
s:save()
pending.filter('priority')
local hidden = buffer.hidden_ids()
local tasks = store.active_tasks()
local tasks = s:active_tasks()
local important_task, normal_task
for _, t in ipairs(tasks) do
if t.priority and t.priority > 0 then
@ -149,14 +147,14 @@ describe('filter', function()
end)
it('multi-predicate AND: cat:Work + overdue', function()
store.load()
store.add({ description = 'Work overdue', category = 'Work', due = '2020-01-01' })
store.add({ description = 'Work future', category = 'Work', due = '2099-01-01' })
store.add({ description = 'Home overdue', category = 'Home', due = '2020-01-01' })
store.save()
local s = pending.store()
s:add({ description = 'Work overdue', category = 'Work', due = '2020-01-01' })
s:add({ description = 'Work future', category = 'Work', due = '2099-01-01' })
s:add({ description = 'Home overdue', category = 'Home', due = '2020-01-01' })
s:save()
pending.filter('cat:Work overdue')
local hidden = buffer.hidden_ids()
local tasks = store.active_tasks()
local tasks = s:active_tasks()
local work_overdue, work_future, home_overdue
for _, t in ipairs(tasks) do
if t.description == 'Work overdue' then
@ -175,10 +173,10 @@ describe('filter', function()
end)
it('filter clear removes all predicates and hidden ids', function()
store.load()
store.add({ description = 'Work task', category = 'Work' })
store.add({ description = 'Home task', category = 'Home' })
store.save()
local s = pending.store()
s:add({ description = 'Work task', category = 'Work' })
s:add({ description = 'Home task', category = 'Home' })
s:save()
pending.filter('cat:Work')
assert.are.equal(1, #buffer.filter_predicates())
pending.filter('clear')
@ -187,9 +185,9 @@ describe('filter', function()
end)
it('filter empty string clears filter', function()
store.load()
store.add({ description = 'Work task', category = 'Work' })
store.save()
local s = pending.store()
s:add({ description = 'Work task', category = 'Work' })
s:save()
pending.filter('cat:Work')
assert.are.equal(1, #buffer.filter_predicates())
pending.filter('')
@ -197,16 +195,16 @@ describe('filter', function()
end)
it('filter predicates persist across set_filter calls', function()
store.load()
store.add({ description = 'Work task', category = 'Work' })
store.add({ description = 'Home task', category = 'Home' })
store.save()
local s = pending.store()
s:add({ description = 'Work task', category = 'Work' })
s:add({ description = 'Home task', category = 'Home' })
s:save()
pending.filter('cat:Work')
local preds = buffer.filter_predicates()
assert.are.equal(1, #preds)
assert.are.equal('cat:Work', preds[1])
local hidden = buffer.hidden_ids()
local tasks = store.active_tasks()
local tasks = s:active_tasks()
local home_task
for _, t in ipairs(tasks) do
if t.category == 'Home' then
@ -219,11 +217,11 @@ describe('filter', function()
describe('diff.apply with hidden_ids', function()
it('does not mark hidden tasks as deleted', function()
store.load()
store.add({ description = 'Visible task' })
store.add({ description = 'Hidden task' })
store.save()
local tasks = store.active_tasks()
local s = pending.store()
s:add({ description = 'Visible task' })
s:add({ description = 'Hidden task' })
s:save()
local tasks = s:active_tasks()
local hidden_task
for _, t in ipairs(tasks) do
if t.description == 'Hidden task' then
@ -234,19 +232,18 @@ describe('filter', function()
local lines = {
'/1/- [ ] Visible task',
}
diff.apply(lines, hidden_ids)
store.unload()
store.load()
local hidden = store.get(hidden_task.id)
diff.apply(lines, s, hidden_ids)
s:load()
local hidden = s:get(hidden_task.id)
assert.are.equal('pending', hidden.status)
end)
it('marks tasks deleted when not hidden and not in buffer', function()
store.load()
store.add({ description = 'Keep task' })
store.add({ description = 'Delete task' })
store.save()
local tasks = store.active_tasks()
local s = pending.store()
s:add({ description = 'Keep task' })
s:add({ description = 'Delete task' })
s:save()
local tasks = s:active_tasks()
local keep_task, delete_task
for _, t in ipairs(tasks) do
if t.description == 'Keep task' then
@ -259,27 +256,25 @@ describe('filter', function()
local lines = {
'/' .. keep_task.id .. '/- [ ] Keep task',
}
diff.apply(lines, {})
store.unload()
store.load()
local deleted = store.get(delete_task.id)
diff.apply(lines, s, {})
s:load()
local deleted = s:get(delete_task.id)
assert.are.equal('deleted', deleted.status)
end)
it('strips FILTER: line before parsing', function()
store.load()
store.add({ description = 'My task' })
store.save()
local tasks = store.active_tasks()
local s = pending.store()
s:add({ description = 'My task' })
s:save()
local tasks = s:active_tasks()
local task = tasks[1]
local lines = {
'FILTER: cat:Work',
'/' .. task.id .. '/- [ ] My task',
}
diff.apply(lines, {})
store.unload()
store.load()
local t = store.get(task.id)
diff.apply(lines, s, {})
s:load()
local t = s:get(task.id)
assert.are.equal('pending', t.status)
end)

View file

@ -2,7 +2,6 @@ require('spec.helpers')
local config = require('pending.config')
local parse = require('pending.parse')
local store = require('pending.store')
describe('status', function()
local tmpdir
@ -13,22 +12,20 @@ describe('status', function()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
package.loaded['pending'] = nil
pending = require('pending')
pending.store():load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
store.unload()
package.loaded['pending'] = nil
end)
describe('counts', function()
it('returns zeroes for empty store', function()
store.load()
local c = pending.counts()
assert.are.equal(0, c.overdue)
assert.are.equal(0, c.today)
@ -38,48 +35,48 @@ describe('status', function()
end)
it('counts pending tasks', function()
store.load()
store.add({ description = 'One' })
store.add({ description = 'Two' })
store.save()
local s = pending.store()
s:add({ description = 'One' })
s:add({ description = 'Two' })
s:save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(2, c.pending)
end)
it('counts priority tasks', function()
store.load()
store.add({ description = 'Urgent', priority = 1 })
store.add({ description = 'Normal' })
store.save()
local s = pending.store()
s:add({ description = 'Urgent', priority = 1 })
s:add({ description = 'Normal' })
s:save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.priority)
end)
it('counts overdue tasks with date-only', function()
store.load()
store.add({ description = 'Old task', due = '2020-01-01' })
store.save()
local s = pending.store()
s:add({ description = 'Old task', due = '2020-01-01' })
s:save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.overdue)
end)
it('counts overdue tasks with datetime', function()
store.load()
store.add({ description = 'Old task', due = '2020-01-01T08:00' })
store.save()
local s = pending.store()
s:add({ description = 'Old task', due = '2020-01-01T08:00' })
s:save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.overdue)
end)
it('counts today tasks', function()
store.load()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Today task', due = today })
store.save()
s:add({ description = 'Today task', due = today })
s:save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.today)
@ -87,11 +84,11 @@ describe('status', function()
end)
it('counts mixed overdue and today', function()
store.load()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Overdue', due = '2020-01-01' })
store.add({ description = 'Today', due = today })
store.save()
s:add({ description = 'Overdue', due = '2020-01-01' })
s:add({ description = 'Today', due = today })
s:save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.overdue)
@ -99,10 +96,10 @@ describe('status', function()
end)
it('excludes done tasks', function()
store.load()
local t = store.add({ description = 'Done', due = '2020-01-01' })
store.update(t.id, { status = 'done' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Done', due = '2020-01-01' })
s:update(t.id, { status = 'done' })
s:save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(0, c.overdue)
@ -110,10 +107,10 @@ describe('status', function()
end)
it('excludes deleted tasks', function()
store.load()
local t = store.add({ description = 'Deleted', due = '2020-01-01' })
store.delete(t.id)
store.save()
local s = pending.store()
local t = s:add({ description = 'Deleted', due = '2020-01-01' })
s:delete(t.id)
s:save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(0, c.overdue)
@ -121,9 +118,9 @@ describe('status', function()
end)
it('excludes someday sentinel', function()
store.load()
store.add({ description = 'Someday', due = '9999-12-30' })
store.save()
local s = pending.store()
s:add({ description = 'Someday', due = '9999-12-30' })
s:save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(0, c.overdue)
@ -132,12 +129,12 @@ describe('status', function()
end)
it('picks earliest future date as next_due', function()
store.load()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Soon', due = '2099-06-01' })
store.add({ description = 'Sooner', due = '2099-03-01' })
store.add({ description = 'Today', due = today })
store.save()
s:add({ description = 'Soon', due = '2099-06-01' })
s:add({ description = 'Sooner', due = '2099-03-01' })
s:add({ description = 'Today', due = today })
s:save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(today, c.next_due)
@ -161,7 +158,6 @@ describe('status', function()
},
}))
f:close()
store.unload()
package.loaded['pending'] = nil
pending = require('pending')
local c = pending.counts()
@ -171,35 +167,35 @@ describe('status', function()
describe('statusline', function()
it('returns empty string when nothing actionable', function()
store.load()
store.save()
local s = pending.store()
s:save()
pending._recompute_counts()
assert.are.equal('', pending.statusline())
end)
it('formats overdue only', function()
store.load()
store.add({ description = 'Old', due = '2020-01-01' })
store.save()
local s = pending.store()
s:add({ description = 'Old', due = '2020-01-01' })
s:save()
pending._recompute_counts()
assert.are.equal('1 overdue', pending.statusline())
end)
it('formats today only', function()
store.load()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Today', due = today })
store.save()
s:add({ description = 'Today', due = today })
s:save()
pending._recompute_counts()
assert.are.equal('1 today', pending.statusline())
end)
it('formats overdue and today', function()
store.load()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Old', due = '2020-01-01' })
store.add({ description = 'Today', due = today })
store.save()
s:add({ description = 'Old', due = '2020-01-01' })
s:add({ description = 'Today', due = today })
s:save()
pending._recompute_counts()
assert.are.equal('1 overdue, 1 today', pending.statusline())
end)
@ -207,26 +203,26 @@ describe('status', function()
describe('has_due', function()
it('returns false when nothing due', function()
store.load()
store.add({ description = 'Future', due = '2099-01-01' })
store.save()
local s = pending.store()
s:add({ description = 'Future', due = '2099-01-01' })
s:save()
pending._recompute_counts()
assert.is_false(pending.has_due())
end)
it('returns true when overdue', function()
store.load()
store.add({ description = 'Old', due = '2020-01-01' })
store.save()
local s = pending.store()
s:add({ description = 'Old', due = '2020-01-01' })
s:save()
pending._recompute_counts()
assert.is_true(pending.has_due())
end)
it('returns true when today', function()
store.load()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Now', due = today })
store.save()
s:add({ description = 'Now', due = today })
s:save()
pending._recompute_counts()
assert.is_true(pending.has_due())
end)

View file

@ -5,31 +5,30 @@ local store = require('pending.store')
describe('store', function()
local tmpdir
local s
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
s = store.new(tmpdir .. '/tasks.json')
s:load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
end)
describe('load', function()
it('returns empty data when no file exists', function()
local data = store.load()
local data = s:load()
assert.are.equal(1, data.version)
assert.are.equal(1, data.next_id)
assert.are.same({}, data.tasks)
end)
it('loads existing data', function()
local path = config.get().data_path
local path = tmpdir .. '/tasks.json'
local f = io.open(path, 'w')
f:write(vim.json.encode({
version = 1,
@ -52,7 +51,7 @@ describe('store', function()
},
}))
f:close()
local data = store.load()
local data = s:load()
assert.are.equal(3, data.next_id)
assert.are.equal(2, #data.tasks)
assert.are.equal('Pending one', data.tasks[1].description)
@ -60,7 +59,7 @@ describe('store', function()
end)
it('preserves unknown fields', function()
local path = config.get().data_path
local path = tmpdir .. '/tasks.json'
local f = io.open(path, 'w')
f:write(vim.json.encode({
version = 1,
@ -77,8 +76,8 @@ describe('store', function()
},
}))
f:close()
store.load()
local task = store.get(1)
s:load()
local task = s:get(1)
assert.is_not_nil(task._extra)
assert.are.equal('hello', task._extra.custom_field)
end)
@ -86,9 +85,8 @@ describe('store', function()
describe('add', function()
it('creates a task with incremented id', function()
store.load()
local t1 = store.add({ description = 'First' })
local t2 = store.add({ description = 'Second' })
local t1 = s:add({ description = 'First' })
local t2 = s:add({ description = 'Second' })
assert.are.equal(1, t1.id)
assert.are.equal(2, t2.id)
assert.are.equal('pending', t1.status)
@ -96,60 +94,54 @@ describe('store', function()
end)
it('uses provided category', function()
store.load()
local t = store.add({ description = 'Test', category = 'Work' })
local t = s:add({ description = 'Test', category = 'Work' })
assert.are.equal('Work', t.category)
end)
end)
describe('update', function()
it('updates fields and sets modified', function()
store.load()
local t = store.add({ description = 'Original' })
local t = s:add({ description = 'Original' })
t.modified = '2025-01-01T00:00:00Z'
store.update(t.id, { description = 'Updated' })
local updated = store.get(t.id)
s:update(t.id, { description = 'Updated' })
local updated = s:get(t.id)
assert.are.equal('Updated', updated.description)
assert.is_not.equal('2025-01-01T00:00:00Z', updated.modified)
end)
it('sets end timestamp on completion', function()
store.load()
local t = store.add({ description = 'Test' })
local t = s:add({ description = 'Test' })
assert.is_nil(t['end'])
store.update(t.id, { status = 'done' })
local updated = store.get(t.id)
s:update(t.id, { status = 'done' })
local updated = s:get(t.id)
assert.is_not_nil(updated['end'])
end)
it('does not overwrite id or entry', function()
store.load()
local t = store.add({ description = 'Immutable fields' })
local t = s:add({ description = 'Immutable fields' })
local original_id = t.id
local original_entry = t.entry
store.update(t.id, { id = 999, entry = 'x' })
local updated = store.get(original_id)
s:update(t.id, { id = 999, entry = 'x' })
local updated = s:get(original_id)
assert.are.equal(original_id, updated.id)
assert.are.equal(original_entry, updated.entry)
end)
it('does not overwrite end on second completion', function()
store.load()
local t = store.add({ description = 'Complete twice' })
store.update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' })
local first_end = store.get(t.id)['end']
store.update(t.id, { status = 'done' })
local task = store.get(t.id)
local t = s:add({ description = 'Complete twice' })
s:update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' })
local first_end = s:get(t.id)['end']
s:update(t.id, { status = 'done' })
local task = s:get(t.id)
assert.are.equal(first_end, task['end'])
end)
end)
describe('delete', function()
it('marks task as deleted', function()
store.load()
local t = store.add({ description = 'To delete' })
store.delete(t.id)
local deleted = store.get(t.id)
local t = s:add({ description = 'To delete' })
s:delete(t.id)
local deleted = s:get(t.id)
assert.are.equal('deleted', deleted.status)
assert.is_not_nil(deleted['end'])
end)
@ -157,12 +149,10 @@ describe('store', function()
describe('save and round-trip', function()
it('persists and reloads correctly', function()
store.load()
store.add({ description = 'Persisted', category = 'Work', priority = 1 })
store.save()
store.unload()
store.load()
local tasks = store.active_tasks()
s:add({ description = 'Persisted', category = 'Work', priority = 1 })
s:save()
s:load()
local tasks = s:active_tasks()
assert.are.equal(1, #tasks)
assert.are.equal('Persisted', tasks[1].description)
assert.are.equal('Work', tasks[1].category)
@ -170,7 +160,7 @@ describe('store', function()
end)
it('round-trips unknown fields', function()
local path = config.get().data_path
local path = tmpdir .. '/tasks.json'
local f = io.open(path, 'w')
f:write(vim.json.encode({
version = 1,
@ -187,45 +177,38 @@ describe('store', function()
},
}))
f:close()
store.load()
store.save()
store.unload()
store.load()
local task = store.get(1)
s:load()
s:save()
s:load()
local task = s:get(1)
assert.are.equal('abc123', task._extra._gcal_event_id)
end)
end)
describe('recurrence fields', function()
it('persists recur and recur_mode through round-trip', function()
store.load()
store.add({ description = 'Recurring', recur = 'weekly', recur_mode = 'scheduled' })
store.save()
store.unload()
store.load()
local task = store.get(1)
s:add({ description = 'Recurring', recur = 'weekly', recur_mode = 'scheduled' })
s:save()
s:load()
local task = s:get(1)
assert.are.equal('weekly', task.recur)
assert.are.equal('scheduled', task.recur_mode)
end)
it('persists recur without recur_mode', function()
store.load()
store.add({ description = 'Simple recur', recur = 'daily' })
store.save()
store.unload()
store.load()
local task = store.get(1)
s:add({ description = 'Simple recur', recur = 'daily' })
s:save()
s:load()
local task = s:get(1)
assert.are.equal('daily', task.recur)
assert.is_nil(task.recur_mode)
end)
it('omits recur fields when not set', function()
store.load()
store.add({ description = 'No recur' })
store.save()
store.unload()
store.load()
local task = store.get(1)
s:add({ description = 'No recur' })
s:save()
s:load()
local task = s:get(1)
assert.is_nil(task.recur)
assert.is_nil(task.recur_mode)
end)
@ -233,11 +216,10 @@ describe('store', function()
describe('active_tasks', function()
it('excludes deleted tasks', function()
store.load()
store.add({ description = 'Active' })
local t2 = store.add({ description = 'To delete' })
store.delete(t2.id)
local active = store.active_tasks()
s:add({ description = 'Active' })
local t2 = s:add({ description = 'To delete' })
s:delete(t2.id)
local active = s:active_tasks()
assert.are.equal(1, #active)
assert.are.equal('Active', active[1].description)
end)
@ -245,27 +227,24 @@ describe('store', function()
describe('snapshot', function()
it('returns a table of tasks', function()
store.load()
store.add({ description = 'Snap one' })
store.add({ description = 'Snap two' })
local snap = store.snapshot()
s:add({ description = 'Snap one' })
s:add({ description = 'Snap two' })
local snap = s:snapshot()
assert.are.equal(2, #snap)
end)
it('returns a copy that does not affect the store', function()
store.load()
local t = store.add({ description = 'Original' })
local snap = store.snapshot()
local t = s:add({ description = 'Original' })
local snap = s:snapshot()
snap[1].description = 'Mutated'
local live = store.get(t.id)
local live = s:get(t.id)
assert.are.equal('Original', live.description)
end)
it('excludes deleted tasks', function()
store.load()
local t = store.add({ description = 'Will be deleted' })
store.delete(t.id)
local snap = store.snapshot()
local t = s:add({ description = 'Will be deleted' })
s:delete(t.id)
local snap = s:snapshot()
assert.are.equal(0, #snap)
end)
end)

View file

@ -1,7 +1,6 @@
require('spec.helpers')
local config = require('pending.config')
local store = require('pending.store')
describe('sync', function()
local tmpdir
@ -12,7 +11,6 @@ describe('sync', function()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
package.loaded['pending'] = nil
pending = require('pending')
end)
@ -21,7 +19,6 @@ describe('sync', function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
store.unload()
package.loaded['pending'] = nil
end)

View file

@ -5,28 +5,27 @@ local store = require('pending.store')
describe('views', function()
local tmpdir
local s
local views = require('pending.views')
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
store.load()
s = store.new(tmpdir .. '/tasks.json')
s:load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
end)
describe('category_view', function()
it('groups tasks under their category header', function()
store.add({ description = 'Task A', category = 'Work' })
store.add({ description = 'Task B', category = 'Work' })
local lines, meta = views.category_view(store.active_tasks())
s:add({ description = 'Task A', category = 'Work' })
s:add({ description = 'Task B', category = 'Work' })
local lines, meta = views.category_view(s:active_tasks())
assert.are.equal('## Work', lines[1])
assert.are.equal('header', meta[1].type)
assert.is_true(lines[2]:find('Task A') ~= nil)
@ -34,10 +33,10 @@ describe('views', function()
end)
it('places pending tasks before done tasks within a category', function()
local t1 = store.add({ description = 'Done task', category = 'Work' })
store.add({ description = 'Pending task', category = 'Work' })
store.update(t1.id, { status = 'done' })
local _, meta = views.category_view(store.active_tasks())
local t1 = s:add({ description = 'Done task', category = 'Work' })
s:add({ description = 'Pending task', category = 'Work' })
s:update(t1.id, { status = 'done' })
local _, meta = views.category_view(s:active_tasks())
local pending_row, done_row
for i, m in ipairs(meta) do
if m.type == 'task' and m.status == 'pending' then
@ -50,9 +49,9 @@ describe('views', function()
end)
it('sorts high-priority tasks before normal tasks within pending group', function()
store.add({ description = 'Normal', category = 'Work', priority = 0 })
store.add({ description = 'High', category = 'Work', priority = 1 })
local lines, meta = views.category_view(store.active_tasks())
s:add({ description = 'Normal', category = 'Work', priority = 0 })
s:add({ description = 'High', category = 'Work', priority = 1 })
local lines, meta = views.category_view(s:active_tasks())
local high_row, normal_row
for i, m in ipairs(meta) do
if m.type == 'task' then
@ -68,11 +67,11 @@ describe('views', function()
end)
it('sorts high-priority tasks before normal tasks within done group', function()
local t1 = store.add({ description = 'Done Normal', category = 'Work', priority = 0 })
local t2 = store.add({ description = 'Done High', category = 'Work', priority = 1 })
store.update(t1.id, { status = 'done' })
store.update(t2.id, { status = 'done' })
local lines, meta = views.category_view(store.active_tasks())
local t1 = s:add({ description = 'Done Normal', category = 'Work', priority = 0 })
local t2 = s:add({ description = 'Done High', category = 'Work', priority = 1 })
s:update(t1.id, { status = 'done' })
s:update(t2.id, { status = 'done' })
local lines, meta = views.category_view(s:active_tasks())
local high_row, normal_row
for i, m in ipairs(meta) do
if m.type == 'task' then
@ -88,9 +87,9 @@ describe('views', function()
end)
it('gives each category its own header with blank lines between them', function()
store.add({ description = 'Task A', category = 'Work' })
store.add({ description = 'Task B', category = 'Personal' })
local lines, meta = views.category_view(store.active_tasks())
s:add({ description = 'Task A', category = 'Work' })
s:add({ description = 'Task B', category = 'Personal' })
local lines, meta = views.category_view(s:active_tasks())
local headers = {}
local blank_found = false
for i, m in ipairs(meta) do
@ -105,8 +104,8 @@ describe('views', function()
end)
it('formats task lines as /ID/ description', function()
store.add({ description = 'My task', category = 'Inbox' })
local lines, meta = views.category_view(store.active_tasks())
s:add({ description = 'My task', category = 'Inbox' })
local lines, meta = views.category_view(s:active_tasks())
local task_line
for i, m in ipairs(meta) do
if m.type == 'task' then
@ -117,8 +116,8 @@ describe('views', function()
end)
it('formats priority task lines as /ID/- [!] description', function()
store.add({ description = 'Important', category = 'Inbox', priority = 1 })
local lines, meta = views.category_view(store.active_tasks())
s:add({ description = 'Important', category = 'Inbox', priority = 1 })
local lines, meta = views.category_view(s:active_tasks())
local task_line
for i, m in ipairs(meta) do
if m.type == 'task' then
@ -129,15 +128,15 @@ describe('views', function()
end)
it('sets LineMeta type=header for header lines with correct category', function()
store.add({ description = 'T', category = 'School' })
local _, meta = views.category_view(store.active_tasks())
s:add({ description = 'T', category = 'School' })
local _, meta = views.category_view(s:active_tasks())
assert.are.equal('header', meta[1].type)
assert.are.equal('School', meta[1].category)
end)
it('sets LineMeta type=task with correct id and status', function()
local t = store.add({ description = 'Do something', category = 'Inbox' })
local _, meta = views.category_view(store.active_tasks())
local t = s:add({ description = 'Do something', category = 'Inbox' })
local _, meta = views.category_view(s:active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' then
@ -150,9 +149,9 @@ describe('views', function()
end)
it('sets LineMeta type=blank for blank separator lines', function()
store.add({ description = 'A', category = 'Work' })
store.add({ description = 'B', category = 'Home' })
local _, meta = views.category_view(store.active_tasks())
s:add({ description = 'A', category = 'Work' })
s:add({ description = 'B', category = 'Home' })
local _, meta = views.category_view(s:active_tasks())
local blank_meta
for _, m in ipairs(meta) do
if m.type == 'blank' then
@ -166,8 +165,8 @@ describe('views', function()
it('marks overdue pending tasks with meta.overdue=true', function()
local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
local t = store.add({ description = 'Overdue task', category = 'Inbox', due = yesterday })
local _, meta = views.category_view(store.active_tasks())
local t = s:add({ description = 'Overdue task', category = 'Inbox', due = yesterday })
local _, meta = views.category_view(s:active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
@ -179,8 +178,8 @@ describe('views', function()
it('does not mark future pending tasks as overdue', function()
local tomorrow = os.date('%Y-%m-%d', os.time() + 86400)
local t = store.add({ description = 'Future task', category = 'Inbox', due = tomorrow })
local _, meta = views.category_view(store.active_tasks())
local t = s:add({ description = 'Future task', category = 'Inbox', due = tomorrow })
local _, meta = views.category_view(s:active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
@ -192,9 +191,9 @@ describe('views', function()
it('does not mark done tasks with overdue due dates as overdue', function()
local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
local t = store.add({ description = 'Done late', category = 'Inbox', due = yesterday })
store.update(t.id, { status = 'done' })
local _, meta = views.category_view(store.active_tasks())
local t = s:add({ description = 'Done late', category = 'Inbox', due = yesterday })
s:update(t.id, { status = 'done' })
local _, meta = views.category_view(s:active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
@ -205,8 +204,8 @@ describe('views', function()
end)
it('includes recur in LineMeta for recurring tasks', function()
store.add({ description = 'Recurring', category = 'Inbox', recur = 'weekly' })
local _, meta = views.category_view(store.active_tasks())
s:add({ description = 'Recurring', category = 'Inbox', recur = 'weekly' })
local _, meta = views.category_view(s:active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' then
@ -217,8 +216,8 @@ describe('views', function()
end)
it('has nil recur in LineMeta for non-recurring tasks', function()
store.add({ description = 'Normal', category = 'Inbox' })
local _, meta = views.category_view(store.active_tasks())
s:add({ description = 'Normal', category = 'Inbox' })
local _, meta = views.category_view(s:active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' then
@ -231,9 +230,9 @@ describe('views', function()
it('respects category_order when set', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work', 'Inbox' } }
config.reset()
store.add({ description = 'Inbox task', category = 'Inbox' })
store.add({ description = 'Work task', category = 'Work' })
local lines, meta = views.category_view(store.active_tasks())
s:add({ description = 'Inbox task', category = 'Inbox' })
s:add({ description = 'Work task', category = 'Work' })
local lines, meta = views.category_view(s:active_tasks())
local first_header, second_header
for i, m in ipairs(meta) do
if m.type == 'header' then
@ -251,9 +250,9 @@ describe('views', function()
it('appends categories not in category_order after ordered ones', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work' } }
config.reset()
store.add({ description = 'Errand', category = 'Errands' })
store.add({ description = 'Work task', category = 'Work' })
local lines, meta = views.category_view(store.active_tasks())
s:add({ description = 'Errand', category = 'Errands' })
s:add({ description = 'Work task', category = 'Work' })
local lines, meta = views.category_view(s:active_tasks())
local headers = {}
for i, m in ipairs(meta) do
if m.type == 'header' then
@ -265,9 +264,9 @@ describe('views', function()
end)
it('preserves insertion order when category_order is empty', function()
store.add({ description = 'Alpha task', category = 'Alpha' })
store.add({ description = 'Beta task', category = 'Beta' })
local lines, meta = views.category_view(store.active_tasks())
s:add({ description = 'Alpha task', category = 'Alpha' })
s:add({ description = 'Beta task', category = 'Beta' })
local lines, meta = views.category_view(s:active_tasks())
local headers = {}
for i, m in ipairs(meta) do
if m.type == 'header' then
@ -281,10 +280,10 @@ describe('views', function()
describe('priority_view', function()
it('places all pending tasks before done tasks', function()
local t1 = store.add({ description = 'Done A', category = 'Work' })
store.add({ description = 'Pending B', category = 'Work' })
store.update(t1.id, { status = 'done' })
local _, meta = views.priority_view(store.active_tasks())
local t1 = s:add({ description = 'Done A', category = 'Work' })
s:add({ description = 'Pending B', category = 'Work' })
s:update(t1.id, { status = 'done' })
local _, meta = views.priority_view(s:active_tasks())
local last_pending_row, first_done_row
for i, m in ipairs(meta) do
if m.type == 'task' then
@ -299,9 +298,9 @@ describe('views', function()
end)
it('sorts pending tasks by priority desc within pending group', function()
store.add({ description = 'Low', category = 'Work', priority = 0 })
store.add({ description = 'High', category = 'Work', priority = 1 })
local lines, meta = views.priority_view(store.active_tasks())
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
@ -316,9 +315,9 @@ describe('views', function()
end)
it('sorts pending tasks with due dates before those without', function()
store.add({ description = 'No due', category = 'Work' })
store.add({ description = 'Has due', category = 'Work', due = '2099-12-31' })
local lines, meta = views.priority_view(store.active_tasks())
s:add({ description = 'No due', category = 'Work' })
s:add({ description = 'Has due', category = 'Work', due = '2099-12-31' })
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
@ -333,9 +332,9 @@ describe('views', function()
end)
it('sorts pending tasks with earlier due dates before later due dates', function()
store.add({ description = 'Later', category = 'Work', due = '2099-12-31' })
store.add({ description = 'Earlier', category = 'Work', due = '2050-01-01' })
local lines, meta = views.priority_view(store.active_tasks())
s:add({ description = 'Later', category = 'Work', due = '2099-12-31' })
s:add({ description = 'Earlier', category = 'Work', due = '2050-01-01' })
local lines, meta = views.priority_view(s:active_tasks())
local earlier_row, later_row
for i, m in ipairs(meta) do
if m.type == 'task' then
@ -350,15 +349,15 @@ describe('views', function()
end)
it('formats task lines as /ID/- [ ] description', function()
store.add({ description = 'My task', category = 'Inbox' })
local lines, _ = views.priority_view(store.active_tasks())
s:add({ description = 'My task', category = 'Inbox' })
local lines, _ = views.priority_view(s:active_tasks())
assert.are.equal('/1/- [ ] My task', lines[1])
end)
it('sets show_category=true for all task meta entries', function()
store.add({ description = 'T1', category = 'Work' })
store.add({ description = 'T2', category = 'Personal' })
local _, meta = views.priority_view(store.active_tasks())
s:add({ description = 'T1', category = 'Work' })
s:add({ description = 'T2', category = 'Personal' })
local _, meta = views.priority_view(s:active_tasks())
for _, m in ipairs(meta) do
if m.type == 'task' then
assert.is_true(m.show_category == true)
@ -367,9 +366,9 @@ describe('views', function()
end)
it('sets meta.category correctly for each task', function()
store.add({ description = 'Work task', category = 'Work' })
store.add({ description = 'Home task', category = 'Home' })
local lines, meta = views.priority_view(store.active_tasks())
s:add({ description = 'Work task', category = 'Work' })
s:add({ description = 'Home task', category = 'Home' })
local lines, meta = views.priority_view(s:active_tasks())
local categories = {}
for i, m in ipairs(meta) do
if m.type == 'task' then
@ -386,8 +385,8 @@ describe('views', function()
it('marks overdue pending tasks with meta.overdue=true', function()
local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
local t = store.add({ description = 'Overdue', category = 'Inbox', due = yesterday })
local _, meta = views.priority_view(store.active_tasks())
local t = s:add({ description = 'Overdue', category = 'Inbox', due = yesterday })
local _, meta = views.priority_view(s:active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
@ -399,8 +398,8 @@ describe('views', function()
it('does not mark future pending tasks as overdue', function()
local tomorrow = os.date('%Y-%m-%d', os.time() + 86400)
local t = store.add({ description = 'Future', category = 'Inbox', due = tomorrow })
local _, meta = views.priority_view(store.active_tasks())
local t = s:add({ description = 'Future', category = 'Inbox', due = tomorrow })
local _, meta = views.priority_view(s:active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
@ -412,9 +411,9 @@ describe('views', function()
it('does not mark done tasks with overdue due dates as overdue', function()
local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
local t = store.add({ description = 'Done late', category = 'Inbox', due = yesterday })
store.update(t.id, { status = 'done' })
local _, meta = views.priority_view(store.active_tasks())
local t = s:add({ description = 'Done late', category = 'Inbox', due = yesterday })
s:update(t.id, { status = 'done' })
local _, meta = views.priority_view(s:active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
@ -425,8 +424,8 @@ describe('views', function()
end)
it('includes recur in LineMeta for recurring tasks', function()
store.add({ description = 'Recurring', category = 'Inbox', recur = 'daily' })
local _, meta = views.priority_view(store.active_tasks())
s:add({ description = 'Recurring', category = 'Inbox', recur = 'daily' })
local _, meta = views.priority_view(s:active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' then
@ -437,8 +436,8 @@ describe('views', function()
end)
it('has nil recur in LineMeta for non-recurring tasks', function()
store.add({ description = 'Normal', category = 'Inbox' })
local _, meta = views.priority_view(store.active_tasks())
s:add({ description = 'Normal', category = 'Inbox' })
local _, meta = views.priority_view(s:active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' then