feat: overdue highlighting, relative dates, undo write, buffer mappings (#1)

* feat(config): add category_order field

Problem: category display order was always insertion order with no way
to configure it.

Solution: add category_order to config defaults so users can declare a
preferred category ordering; unspecified categories append after.

* feat(parse): add relative date resolution

Problem: due dates required full YYYY-MM-DD input, adding friction for
common cases like "today" or "next monday".

Solution: add resolve_date() supporting today, tomorrow, +Nd, and
weekday abbreviations; extend inline token parsing to resolve relative
values before falling back to strict date validation.

* feat(views): overdue flag, category in priority view, category ordering

Problem: overdue tasks were visually indistinct from upcoming ones;
priority view had no category context; category display order was not
configurable.

Solution: compute overdue meta flag for pending tasks past their due
date; set show_category on priority view task meta; reorder categories
according to config.category_order when present.

* feat(buffer): overdue highlight, category virt text in priority view

Problem: overdue tasks had no visual distinction; priority view showed
no category context alongside due dates.

Solution: add PendingOverdue highlight group; render category name as
right-aligned virtual text in priority view, composited with the due
date when both are present.

* feat(init): undo write and buffer-local default mappings

Problem: _undo_state was captured on every save but never consumed;
toggle_priority and prompt_date had no buffer-local defaults, requiring
manual <Plug> configuration.

Solution: implement undo_write() to restore pre-save task state; add !,
d, and U as buffer-local defaults following fugitive's philosophy of
owning the buffer; expose :Pending undo as a command alias.

* test(views): add views spec

Problem: views.lua had no test coverage.

Solution: add 26 tests covering category_view and priority_view
including sort order, line format, overdue detection, show_category
meta, and category_order config behavior.

* test(archive): add archive spec

Problem: archive had no test coverage.

Solution: add 9 tests covering cutoff logic, custom day counts, pending
task preservation, deleted task cleanup, and notify output.

* docs: add vimdoc

Problem: no :help documentation existed.

Solution: add doc/pending.txt covering all features — commands,
mappings, views, configuration, Google Calendar sync, highlight groups,
data format, and health check — following standard vimdoc conventions.

* ci: format

* fix: resolve lint and type check errors

Problem: selene flagged unused variables in new spec files; LuaLS
flagged os.date/os.time return type mismatches, integer? assignments,
and stale task.Task/task.GcalConfig type references.

Solution: prefix unused spec variables with _ or drop unnecessary
assignments; add --[[@as string/integer]] casts for os.date and
os.time calls; add category_order field to pending.Config annotation;
fix task.GcalConfig -> pending.GcalConfig and task.Task[] ->
pending.Task[]; add nil guards on meta[row].id before store calls;
cast store.data() return to non-optional.

* ci: format

* fix: sync

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

415
doc/pending.txt Normal file
View file

@ -0,0 +1,415 @@
*pending.txt* Buffer-centric task management for Neovim
Author: Barrett Ruth <br.barrettruth@gmail.com>
License: MIT
==============================================================================
INTRODUCTION *pending.nvim*
pending.nvim is a buffer-centric task manager for Neovim. Tasks live in a
plain, editable buffer — add with `o`, delete with `dd`, reorder with
`dd`/`p`, rename by typing. Writing the buffer with `:w` computes a diff
against the JSON store and applies only the changes. No floating windows,
no special UI, no abstraction between you and your tasks.
The buffer looks like this: >
School
! Read chapter 5 Feb 28
Submit homework Feb 25
Errands
Buy groceries Mar 01
Clean apartment
<
Category headers sit at column 0. Tasks are indented two spaces below them.
`!` marks a priority task. Due dates appear as right-aligned virtual text.
Completed tasks are rendered with strikethrough. Task IDs are embedded as
concealed tokens and are never visible during editing.
Features: ~
- Oil-style buffer editing: standard Vim motions for all task operations
- Inline metadata syntax: `due:` and `cat:` tokens parsed on `:w`
- Relative date input: `today`, `tomorrow`, `+Nd`, weekday names
- Two views: category (default) and priority flat list
- Single-level undo for the last `:w` save
- Quick-add from the command line with `:Pending add`
- Google Calendar one-way push via OAuth PKCE
==============================================================================
REQUIREMENTS *pending-requirements*
- Neovim 0.10+
- No external dependencies for local use
- `curl` and `openssl` are required for Google Calendar sync
==============================================================================
INSTALL *pending-install*
Install with lazy.nvim: >lua
{ 'barrettruth/pending.nvim' }
<
Install with luarocks: >vim
luarocks install pending.nvim
<
No `setup()` call is needed. The plugin loads automatically and works with
defaults. To customize behavior, set |vim.g.pending| before the plugin loads.
See |pending-config|.
==============================================================================
USAGE *pending-usage*
Open the task buffer: >vim
:Pending
<
The buffer named `pending://` opens in the current window. From there, use
standard Vim editing:
- `o` / `O` to add a new task line under or above the cursor
- `dd` to remove a task (deletion is applied on `:w`)
- `dd` + `p` to reorder tasks (pasted tasks receive new IDs)
- `:w` to save — all additions, deletions, and edits are diffed against the
store and committed atomically
Buffer-local keys are set automatically when the buffer opens. See
|pending-mappings| for the full list.
The buffer uses `buftype=acwrite` so `:w` always routes through pending.nvim's
write handler rather than writing to disk directly. The `pending://` buffer
persists across window switches; reopening with `:Pending` focuses the
existing window if one is open.
==============================================================================
INLINE METADATA *pending-metadata*
Metadata tokens may be appended to any task line before saving. Tokens are
parsed from the right and consumed until a non-metadata token is reached.
Supported tokens: ~
`due:YYYY-MM-DD` Set a due date using an absolute date.
`due:today` Resolve to today's date.
`due:tomorrow` Resolve to tomorrow's date.
`due:+Nd` Resolve to N days from today (e.g. `due:+3d`).
`due:mon` Resolve to the next occurrence of that weekday.
Supported: `sun` `mon` `tue` `wed` `thu` `fri` `sat`
`cat:Name` Move the task to the named category on save.
The token name for due dates defaults to `due` and is configurable via
`date_syntax` in |pending-config|. If `date_syntax` is set to `by`, write
`by:2026-03-15` instead.
Example: >
Buy milk due:2026-03-15 cat:Errands
<
On `:w`, the description becomes `Buy milk`, the due date is stored as
`2026-03-15` and rendered as right-aligned virtual text, and the task is
placed under the `Errands` category header.
Parsing stops at the first token that is not a recognised metadata token.
Repeated tokens of the same type also stop parsing — only one `due:` and one
`cat:` per task line are consumed.
==============================================================================
COMMANDS *pending-commands*
*:Pending*
:Pending
Open the task buffer. If the buffer is already displayed in a window,
focus that window. Equivalent to |<Plug>(pending-open)|.
*:Pending-add*
:Pending add {text}
Quick-add a task without opening the buffer. Inline metadata tokens in
{text} are parsed exactly as they are in the buffer. A `Category: ` prefix
(uppercase first letter, colon, space) assigns the category directly: >vim
:Pending add Buy groceries due:2026-03-15
:Pending add School: Submit homework
:Pending add Errands: Pick up dry cleaning due:fri
<
If the buffer is currently open it is re-rendered after the add.
*:Pending-archive*
:Pending archive [{days}]
Permanently remove done and deleted tasks whose completion timestamp is
older than {days} days. {days} defaults to 30 if not provided. >vim
:Pending archive " remove tasks completed more than 30 days ago
:Pending archive 7 " remove tasks completed more than 7 days ago
<
*:Pending-sync*
:Pending sync
Push pending tasks that have a due date to Google Calendar as all-day
events. Requires |pending-gcal| to be configured. See |pending-gcal| for
full details on what gets created, updated, and deleted.
*:Pending-undo*
:Pending undo
There is no `:Pending undo` subcommand. Use the `U` buffer-local key
(see |pending-mappings|) to undo the last `:w` save while the task buffer
is open.
==============================================================================
MAPPINGS *pending-mappings*
The following keys are set buffer-locally when the task buffer opens. They
are active only in the `pending://` buffer.
Buffer-local keys: ~
Key Action ~
------- ------------------------------------------------
`<CR>` Toggle complete / uncomplete the task at cursor
`!` Toggle the priority flag on the task at cursor
`d` Prompt for a due date on the task at cursor
`<Tab>` Switch between category view and priority view
`U` Undo the last `:w` save
`g?` Show a help popup with available keys
Standard Vim keys `o`, `O`, `dd`, `p`, `P`, and `:w` work as expected.
*<Plug>(pending-open)*
<Plug>(pending-open)
Open the task buffer. Maps to |:Pending| with no arguments.
*<Plug>(pending-toggle)*
<Plug>(pending-toggle)
Toggle complete / uncomplete for the task under the cursor.
*<Plug>(pending-priority)*
<Plug>(pending-priority)
Toggle the priority flag for the task under the cursor.
*<Plug>(pending-date)*
<Plug>(pending-date)
Prompt for a due date for the task under the cursor.
*<Plug>(pending-view)*
<Plug>(pending-view)
Switch between category view and priority view.
Example configuration: >lua
vim.keymap.set('n', '<leader>t', '<Plug>(pending-open)')
vim.keymap.set('n', '<leader>T', '<Plug>(pending-toggle)')
<
==============================================================================
VIEWS *pending-views*
Two views are available. Switch with `<Tab>` or |<Plug>(pending-view)|.
Category view (default): ~ *pending-view-category*
Tasks are grouped under their category header. Categories appear in the
order tasks were added unless `category_order` is set (see
|pending-config|). Blank lines separate categories. Within each category,
pending tasks appear before done tasks. Priority tasks (`!`) are sorted
first within each group.
Priority view: ~ *pending-view-priority*
A flat list of all tasks sorted by priority, then by due date (tasks
without a due date sort last), then by internal order. Done tasks appear
after all pending tasks. Category names are shown as right-aligned virtual
text alongside the due date virtual text so tasks remain identifiable
across categories.
==============================================================================
CONFIGURATION *pending-config*
Configuration is done via `vim.g.pending`. Set this before the plugin
loads: >lua
vim.g.pending = {
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
default_view = 'category',
default_category = 'Inbox',
date_format = '%b %d',
date_syntax = 'due',
category_order = {},
gcal = {
calendar = 'Tasks',
credentials_path = '/path/to/client_secret.json',
},
}
<
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.
Default: `stdpath('data') .. '/pending/tasks.json'`.
The directory is created automatically on first save.
{default_view} ('category'|'priority', default: 'category')
The view to use when the buffer is opened for the
first time in a session.
{default_category} (string, default: 'Inbox')
Category assigned to new tasks when no `cat:` token
is present and no `Category: ` prefix is used with
`:Pending add`.
{date_format} (string, default: '%b %d')
strftime format string used to render due dates as
virtual text in the buffer. Examples: `'%Y-%m-%d'`
for ISO dates, `'%d %b'` for day-first.
{date_syntax} (string, default: 'due')
The token name for inline due-date metadata. Change
this to use a different keyword, for example `'by'`
to write `by:2026-03-15` instead of `due:2026-03-15`.
{category_order} (string[], default: {})
Ordered list of category names. In category view,
categories that appear in this list are shown in the
given order. Categories not in the list are appended
after the ordered ones in their natural order.
{gcal} (table, default: nil)
Google Calendar sync configuration. See
|pending.GcalConfig|. Omit this field entirely to
disable Google Calendar sync.
==============================================================================
GOOGLE CALENDAR *pending-gcal*
pending.nvim can push tasks with due dates to a dedicated Google Calendar as
all-day events. This is a one-way push; changes made in Google Calendar are
not pulled back into pending.nvim.
Configuration: >lua
vim.g.pending = {
gcal = {
calendar = 'Tasks',
credentials_path = '/path/to/client_secret.json',
},
}
<
*pending.GcalConfig*
Fields: ~
{calendar} (string, default: 'Pendings')
Name of the Google Calendar to sync to. If a calendar
with this name does not exist it is created
automatically on the first sync.
{credentials_path} (string)
Path to the OAuth client secret JSON file downloaded
from the Google Cloud Console. Default:
`stdpath('data')..'/pending/gcal_credentials.json'`.
The file may be in the `installed` wrapper format
that Google provides or as a bare credentials object.
OAuth flow: ~
On the first `:Pending sync` call the plugin detects that no refresh token
exists and opens the Google authorization URL in the browser using
|vim.ui.open()|. A temporary local HTTP server listens on port 18392 for the
OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used —
`openssl` generates the code challenge. After the user grants consent, the
authorization code is exchanged for tokens and the refresh token is stored at
`stdpath('data')/pending/gcal_tokens.json` with mode `600`. Subsequent syncs
use the stored refresh token and refresh the access token automatically when
it is about to expire.
`:Pending sync` behavior: ~
For each task in the store:
- A pending task with a due date and no existing event: a new all-day event is
created and the event ID is stored in the task's `_extra` table.
- A pending task with a due date and an existing event: the event summary and
date are updated in place.
- A done or deleted task with an existing event: the event is deleted.
- A pending task with no due date that had an existing event: the event is
deleted.
A summary notification is shown after sync: `created: N, updated: N,
deleted: N`.
==============================================================================
HIGHLIGHT GROUPS *pending-highlights*
pending.nvim defines the following highlight groups. All groups are set with
`default`, so colorschemes can override them by defining the group without
`default` before or after the plugin loads.
*PendingHeader*
PendingHeader Applied to category header lines (text at column 0).
Default: bold.
*PendingDue*
PendingDue Applied to the due date virtual text shown at the right
margin of each task line.
Default: fg=#888888, italic.
*PendingDone*
PendingDone Applied to the text of completed tasks.
Default: strikethrough, fg=#666666.
*PendingPriority*
PendingPriority Applied to the `! ` priority marker on priority tasks.
Default: fg=#e06c75, bold.
To override a group in your colorscheme or config: >lua
vim.api.nvim_set_hl(0, 'PendingDue', { fg = '#aaaaaa', italic = true })
<
==============================================================================
HEALTH CHECK *pending-health*
Run |:checkhealth| pending to verify your setup: >vim
:checkhealth pending
<
Checks performed: ~
- Config loads without error
- Reports active configuration values (data path, default view, default
category, date format, date syntax)
- Whether the data directory exists (warning if not yet created)
- Whether the data file exists and can be parsed; reports total task count
- Whether `curl` is available (required for Google Calendar sync)
- Whether `openssl` is available (required for OAuth PKCE)
==============================================================================
DATA FORMAT *pending-data*
Tasks are stored as JSON at `data_path`. 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.
Schema: >
{
"version": 1,
"next_id": 42,
"tasks": [ ... ]
}
<
Task fields: ~
{id} (integer) Unique, auto-incrementing task identifier.
{description} (string) Task text as shown in the buffer.
{status} (string) `'pending'`, `'done'`, or `'deleted'`.
{category} (string) Category name. Defaults to `default_category`.
{priority} (integer) `1` for priority tasks, `0` otherwise.
{due} (string) ISO date string `YYYY-MM-DD`, or absent.
{entry} (string) ISO 8601 UTC timestamp of creation.
{modified} (string) ISO 8601 UTC timestamp of last modification.
{end} (string) ISO 8601 UTC timestamp of completion or deletion.
{order} (integer) Relative ordering within a category.
Any field not in the list above is preserved in `_extra` and written back on
save. This is used internally to store the Google Calendar event ID
(`_gcal_event_id`) and allows third-party tooling to annotate tasks without
data loss.
The `version` field is checked on load. If the file version is newer than the
version the plugin supports, loading is aborted with an error message asking
you to update the plugin.
==============================================================================
vim:tw=78:ts=8:ft=help:norl:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,6 +7,8 @@ local config = require('pending.config')
---@field raw_due? string
---@field status? string
---@field category? string
---@field overdue? boolean
---@field show_category? boolean
---@class pending.views
local M = {}
@ -21,8 +23,12 @@ local function format_due(due)
if not y then
return due
end
local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) })
return os.date(config.get().date_format, t)
local t = os.time({
year = tonumber(y) --[[@as integer]],
month = tonumber(m) --[[@as integer]],
day = tonumber(d) --[[@as integer]],
})
return os.date(config.get().date_format, t) --[[@as string]]
end
---@param tasks pending.Task[]
@ -66,6 +72,7 @@ end
---@return string[] lines
---@return pending.LineMeta[] meta
function M.category_view(tasks)
local today = os.date('%Y-%m-%d') --[[@as string]]
local by_cat = {}
local cat_order = {}
local cat_seen = {}
@ -86,6 +93,24 @@ function M.category_view(tasks)
end
end
local cfg_order = config.get().category_order
if cfg_order and #cfg_order > 0 then
local ordered = {}
local seen = {}
for _, name in ipairs(cfg_order) do
if cat_seen[name] then
table.insert(ordered, name)
seen[name] = true
end
end
for _, name in ipairs(cat_order) do
if not seen[name] then
table.insert(ordered, name)
end
end
cat_order = ordered
end
for _, cat in ipairs(cat_order) do
sort_tasks(by_cat[cat])
sort_tasks(done_by_cat[cat])
@ -123,6 +148,7 @@ function M.category_view(tasks)
raw_due = task.due,
status = task.status,
category = cat,
overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil,
})
end
end
@ -134,6 +160,7 @@ end
---@return string[] lines
---@return pending.LineMeta[] meta
function M.priority_view(tasks)
local today = os.date('%Y-%m-%d') --[[@as string]]
local pending = {}
local done = {}
@ -172,6 +199,8 @@ function M.priority_view(tasks)
raw_due = task.due,
status = task.status,
category = task.category,
overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil,
show_category = true,
})
end

131
spec/archive_spec.lua Normal file
View file

@ -0,0 +1,131 @@
require('spec.helpers')
local config = require('pending.config')
local store = require('pending.store')
describe('archive', function()
local tmpdir
local pending = require('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()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
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' })
pending.archive()
assert.are.equal(0, #store.active_tasks())
end)
it('keeps done tasks completed fewer than 30 days ago', function()
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 })
pending.archive()
local active = store.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 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 })
pending.archive(7)
assert.are.equal(0, #store.active_tasks())
end)
it('keeps tasks within the custom day cutoff', function()
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 })
pending.archive(7)
local active = store.active_tasks()
assert.are.equal(1, #active)
end)
it('never archives pending tasks regardless of age', function()
store.add({ description = 'Still pending' })
pending.archive()
local active = store.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' })
pending.archive()
local all = store.tasks()
assert.are.equal(0, #all)
end)
it('keeps deleted tasks within the cutoff', function()
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 })
pending.archive()
local all = store.tasks()
assert.are.equal(1, #all)
end)
it('reports the correct count in vim.notify', function()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, ...)
table.insert(messages, msg)
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' })
pending.archive()
vim.notify = orig_notify
local found = false
for _, msg in ipairs(messages) do
if msg:find('Archived 2') then
found = true
break
end
end
assert.is_true(found)
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 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 })
pending.archive()
local active = store.active_tasks()
assert.are.equal(2, #active)
local descs = {}
for _, task in ipairs(active) do
descs[task.description] = true
end
assert.is_true(descs['Keep pending'])
assert.is_true(descs['Keep recent done'])
end)
end)

403
spec/views_spec.lua Normal file
View file

@ -0,0 +1,403 @@
require('spec.helpers')
local config = require('pending.config')
local store = require('pending.store')
describe('views', function()
local tmpdir
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()
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())
assert.are.equal('Work', lines[1])
assert.are.equal('header', meta[1].type)
assert.is_true(lines[2]:find('Task A') ~= nil)
assert.is_true(lines[3]:find('Task B') ~= nil)
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 pending_row, done_row
for i, m in ipairs(meta) do
if m.type == 'task' and m.status == 'pending' then
pending_row = i
elseif m.type == 'task' and m.status == 'done' then
done_row = i
end
end
assert.is_true(pending_row < done_row)
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())
local high_row, normal_row
for i, m in ipairs(meta) do
if m.type == 'task' then
local line = lines[i]
if line:find('High') then
high_row = i
elseif line:find('Normal') then
normal_row = i
end
end
end
assert.is_true(high_row < normal_row)
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 high_row, normal_row
for i, m in ipairs(meta) do
if m.type == 'task' then
local line = lines[i]
if line:find('Done High') then
high_row = i
elseif line:find('Done Normal') then
normal_row = i
end
end
end
assert.is_true(high_row < normal_row)
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())
local headers = {}
local blank_found = false
for i, m in ipairs(meta) do
if m.type == 'header' then
table.insert(headers, lines[i])
elseif m.type == 'blank' then
blank_found = true
end
end
assert.are.equal(2, #headers)
assert.is_true(blank_found)
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())
local task_line
for i, m in ipairs(meta) do
if m.type == 'task' then
task_line = lines[i]
end
end
assert.are.equal('/1/ My task', task_line)
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())
local task_line
for i, m in ipairs(meta) do
if m.type == 'task' then
task_line = lines[i]
end
end
assert.are.equal('/1/ ! Important', task_line)
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())
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 task_meta
for _, m in ipairs(meta) do
if m.type == 'task' then
task_meta = m
end
end
assert.are.equal('task', task_meta.type)
assert.are.equal(t.id, task_meta.id)
assert.are.equal('pending', task_meta.status)
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())
local blank_meta
for _, m in ipairs(meta) do
if m.type == 'blank' then
blank_meta = m
break
end
end
assert.is_not_nil(blank_meta)
assert.are.equal('blank', blank_meta.type)
end)
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 task_meta
for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
task_meta = m
end
end
assert.is_true(task_meta.overdue == true)
end)
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 task_meta
for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
task_meta = m
end
end
assert.is_falsy(task_meta.overdue)
end)
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 task_meta
for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
task_meta = m
end
end
assert.is_falsy(task_meta.overdue)
end)
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())
local first_header, second_header
for i, m in ipairs(meta) do
if m.type == 'header' then
if not first_header then
first_header = lines[i]
else
second_header = lines[i]
end
end
end
assert.are.equal('Work', first_header)
assert.are.equal('Inbox', second_header)
end)
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())
local headers = {}
for i, m in ipairs(meta) do
if m.type == 'header' then
table.insert(headers, lines[i])
end
end
assert.are.equal('Work', headers[1])
assert.are.equal('Errands', headers[2])
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())
local headers = {}
for i, m in ipairs(meta) do
if m.type == 'header' then
table.insert(headers, lines[i])
end
end
assert.are.equal('Alpha', headers[1])
assert.are.equal('Beta', headers[2])
end)
end)
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 last_pending_row, first_done_row
for i, m in ipairs(meta) do
if m.type == 'task' then
if m.status == 'pending' then
last_pending_row = i
elseif m.status == 'done' and not first_done_row then
first_done_row = i
end
end
end
assert.is_true(last_pending_row < first_done_row)
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())
local high_row, low_row
for i, m in ipairs(meta) do
if m.type == 'task' then
if lines[i]:find('High') then
high_row = i
elseif lines[i]:find('Low') then
low_row = i
end
end
end
assert.is_true(high_row < low_row)
end)
it('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())
local due_row, nodue_row
for i, m in ipairs(meta) do
if m.type == 'task' then
if lines[i]:find('Has due') then
due_row = i
elseif lines[i]:find('No due') then
nodue_row = i
end
end
end
assert.is_true(due_row < nodue_row)
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())
local earlier_row, later_row
for i, m in ipairs(meta) do
if m.type == 'task' then
if lines[i]:find('Earlier') then
earlier_row = i
elseif lines[i]:find('Later') then
later_row = i
end
end
end
assert.is_true(earlier_row < later_row)
end)
it('formats task lines as /ID/ description', function()
store.add({ description = 'My task', category = 'Inbox' })
local lines, _ = views.priority_view(store.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())
for _, m in ipairs(meta) do
if m.type == 'task' then
assert.is_true(m.show_category == true)
end
end
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())
local categories = {}
for i, m in ipairs(meta) do
if m.type == 'task' then
if lines[i]:find('Work task') then
categories['Work task'] = m.category
elseif lines[i]:find('Home task') then
categories['Home task'] = m.category
end
end
end
assert.are.equal('Work', categories['Work task'])
assert.are.equal('Home', categories['Home task'])
end)
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 task_meta
for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
task_meta = m
end
end
assert.is_true(task_meta.overdue == true)
end)
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 task_meta
for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
task_meta = m
end
end
assert.is_falsy(task_meta.overdue)
end)
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 task_meta
for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
task_meta = m
end
end
assert.is_falsy(task_meta.overdue)
end)
end)
end)