Commit graph

34 commits

Author SHA1 Message Date
666690ad48 fix(buffer): correct extmark drift on open_line above/below done tasks
Problem: `open_line` used `nvim_buf_set_lines` which triggered `on_bytes`
with a `start_row` offset designed for native `o`/`O` keypresses. The
`_meta` entry was inserted one position too late, causing the done task's
`PendingDone` highlight to attach to the new blank line instead.

Solution: suppress `on_bytes` during `open_line` by reusing the
`_rendering` guard, insert the meta entry at the correct position, and
immediately reapply inline extmarks for the affected rows.
2026-03-09 00:12:48 -04:00
Barrett Ruth
b06249f101
feat: complete task editing coverage (#109)
Problem: the task editing surface had gaps — category and recurrence had
no keymaps, `:Pending edit` required knowing the task ID, tasks couldn't
be reordered with a keymap, priority was binary (0/1), and `wip`/`blocked`
states were documented but unimplemented.

Solution: fill every cell so every property is editable in every way.

- `gc`/`gr` keymaps for category select and recurrence prompt
- cursor-aware `:Pending edit` (omit ID to use task under cursor)
- `J`/`K` keymaps to reorder tasks within a category
- multi-level priorities (`max_priority` config, `g!` cycles 0→1→2→3→0)
- `+!!`/`+!!!` tokens in `:Pending edit`, `:Pending add`, `parse.body()`
- `PendingPriority2`/`PendingPriority3` highlight groups
- `gw`/`gb` keymaps toggle `wip`/`blocked` status
- `>`/`=` state chars in buffer rendering and diff parsing
- `PendingWip`/`PendingBlocked` highlight groups
- sort order: wip → pending → blocked → done
- `wip`/`blocked` filter predicates and icons
2026-03-08 19:44:03 -04:00
Barrett Ruth
a43f769383
refactor(config): nest view settings under view key (#103)
Problem: View-related config fields (`default_view`, `eol_format`,
`category_order`, `folding`) are scattered as top-level siblings
alongside unrelated fields like `data_path` and `date_syntax`.

Solution: Group them under a `view` table with per-view sub-tables:
`view.default`, `view.eol_format`, `view.category.order`,
`view.category.folding`, and `view.queue` (empty, ready for #100).
Update all call sites, tests, and vimdoc.
2026-03-08 19:13:17 -04:00
Barrett Ruth
c9471ebe90
feat: persistent inline extmarks and configurable EOL format (#97)
* refactor(buffer): split extmark namespace into `ns_eol` and `ns_inline`

Problem: all extmarks shared a single `pending` namespace, making it
impossible to selectively clear position-sensitive extmarks (overlays,
highlights) while preserving stable EOL virtual text (due dates,
recurrence).

Solution: introduce `ns_eol` for end-of-line virtual text and
`ns_inline` for overlays and highlights. `clear_marks()` and
`apply_extmarks()` operate on both namespaces independently.

* feat(buffer): track line changes via `on_bytes` to keep `_meta` aligned

Problem: `_meta` is a positional array keyed by line number. Line
insertions and deletions during editing desync it from actual buffer
content, breaking `get_fold()`, cursor-based task lookups, and extmark
re-application.

Solution: attach an `on_bytes` callback that adjusts `_meta` on line
insertions/deletions and tracks dirty rows. Remove the manual
`_meta` insert from `open_line()` since `on_bytes` now handles it.
Reset dirty rows on each full render.

* feat(buffer): clear only inline extmarks on dirty rows during edits

Problem: `TextChanged` cleared all extmarks (both namespaces) on every
edit, causing EOL virtual text (due dates, recurrence) to vanish while
the user types.

Solution: replace blanket `clear_marks()` with per-row
`clear_inline_row()` that only removes `ns_inline` extmarks on rows
flagged dirty by `on_bytes`. EOL virtual text is preserved untouched.

* feat(buffer): re-apply inline extmarks after edits

Problem: inline extmarks (checkbox overlays, strikethrough, header
highlights) were cleared during edits and only restored on `:w`,
leaving the buffer visually bare while editing.

Solution: extract `apply_inline_row()` from `apply_extmarks()` and
call it via `reapply_dirty_inline()` on `InsertLeave` and normal-mode
`TextChanged`. Insert-mode `TextChangedI` still only clears inline
marks on dirty rows to avoid overlay flicker while typing.

* fix(buffer): suppress `on_bytes` during render and fix definition order

Problem: `on_bytes` fired during `render()`'s `nvim_buf_set_lines`,
corrupting `_meta` with duplicate entries and causing out-of-range
extmark errors. Also, `apply_inline_row` was defined after its first
caller `reapply_dirty_inline`.

Solution: add `_rendering` guard flag around `nvim_buf_set_lines` in
`render()` so `on_bytes` is a no-op during authoritative renders.
Move `apply_inline_row` above `reapply_dirty_inline` to satisfy Lua
local scoping rules.

* feat(buffer): add configurable `eol_format` for EOL virtual text

Problem: EOL virtual text order (category → recurrence → due) and the
double-space separator are hardcoded in `apply_extmarks()`. Users cannot
reorder, omit, or restyle metadata fields.

Solution: Add `eol_format` config field (default `'%c  %r  %d'`) with
`%c`, `%r`, `%d` specifiers. `parse_eol_format()` tokenizes the format
string; `build_eol_virt()` resolves specifiers against `LineMeta` and
collapses literals around absent fields.

* ci: format
2026-03-08 14:28:10 -04:00
Barrett Ruth
ab06cfcf69
feat(buffer): persist extmarks during editing (#96)
* refactor(buffer): split extmark namespace into `ns_eol` and `ns_inline`

Problem: all extmarks shared a single `pending` namespace, making it
impossible to selectively clear position-sensitive extmarks (overlays,
highlights) while preserving stable EOL virtual text (due dates,
recurrence).

Solution: introduce `ns_eol` for end-of-line virtual text and
`ns_inline` for overlays and highlights. `clear_marks()` and
`apply_extmarks()` operate on both namespaces independently.

* feat(buffer): track line changes via `on_bytes` to keep `_meta` aligned

Problem: `_meta` is a positional array keyed by line number. Line
insertions and deletions during editing desync it from actual buffer
content, breaking `get_fold()`, cursor-based task lookups, and extmark
re-application.

Solution: attach an `on_bytes` callback that adjusts `_meta` on line
insertions/deletions and tracks dirty rows. Remove the manual
`_meta` insert from `open_line()` since `on_bytes` now handles it.
Reset dirty rows on each full render.

* feat(buffer): clear only inline extmarks on dirty rows during edits

Problem: `TextChanged` cleared all extmarks (both namespaces) on every
edit, causing EOL virtual text (due dates, recurrence) to vanish while
the user types.

Solution: replace blanket `clear_marks()` with per-row
`clear_inline_row()` that only removes `ns_inline` extmarks on rows
flagged dirty by `on_bytes`. EOL virtual text is preserved untouched.

* feat(buffer): re-apply inline extmarks after edits

Problem: inline extmarks (checkbox overlays, strikethrough, header
highlights) were cleared during edits and only restored on `:w`,
leaving the buffer visually bare while editing.

Solution: extract `apply_inline_row()` from `apply_extmarks()` and
call it via `reapply_dirty_inline()` on `InsertLeave` and normal-mode
`TextChanged`. Insert-mode `TextChangedI` still only clears inline
marks on dirty rows to avoid overlay flicker while typing.

* fix(buffer): suppress `on_bytes` during render and fix definition order

Problem: `on_bytes` fired during `render()`'s `nvim_buf_set_lines`,
corrupting `_meta` with duplicate entries and causing out-of-range
extmark errors. Also, `apply_inline_row` was defined after its first
caller `reapply_dirty_inline`.

Solution: add `_rendering` guard flag around `nvim_buf_set_lines` in
`render()` so `on_bytes` is a no-op during authoritative renders.
Move `apply_inline_row` above `reapply_dirty_inline` to satisfy Lua
local scoping rules.
2026-03-08 14:19:47 -04:00
d06731a7fd ci: format 2026-03-08 13:24:44 -04:00
Barrett Ruth
9af6086959
feat(buffer): persist fold state across sessions (#94)
Some checks are pending
luarocks / quality (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
Problem: folded category headers are lost when Neovim exits because
`_fold_state` only lives in memory. Users must re-fold categories
every session.

Solution: store folded category names in the JSON data file as a
top-level `folded_categories` field. On first render, `restore_folds`
seeds from the store instead of the empty in-memory state. Folds are
persisted on `M.close()` and `VimLeavePre`.
2026-03-07 20:18:34 -05:00
5fe6dcecad ci: cleanup 2026-03-06 21:36:46 -05:00
Barrett Ruth
559ab863a8
fix(buffer): fix stale extmarks, duplicate window, and fold state loss (#92)
Problem: Deleting lines (`dd`, `dat`, `d3j`) left extmarks stranded on
adjacent rows since `render()` only clears and reapplies marks on `:w`.
Quickfix `<CR>` opened the pending buffer in a second window because
`BufEnter` did not redirect to `task_winid`. Category fold state was
lost across `<Tab>/<Tab>` view toggles because `render()` overwrote the
saved state with an empty snapshot taken while folds were disabled.

Solution: Add a `TextChanged`/`TextChangedI` autocmd that clears the
extmark namespace immediately on any edit. Fix `BufEnter` to close
duplicate windows and redirect focus to `task_winid`, updating it when
stale. Fix `snapshot_folds` to skip if a state is already saved, and
`restore_folds` to always clear the saved state; snapshot in
`toggle_view` before the view flips so the state survives the round-trip.
2026-03-06 21:36:04 -05:00
Barrett Ruth
c392874311
feat(buffer): add configurable category-level folds (#91)
Problem: category folds were hardcoded with no config option, no custom
foldtext, and no vimdoc coverage.

Solution: add `folding` config field (boolean or table with `foldtext`
format string). Default foldtext is `%c (%n tasks)` with automatic
singular/plural. Gate all fold logic on the config so `folding = false`
disables folds entirely. Document the new option in vimdoc.
2026-03-06 20:08:49 -05:00
Barrett Ruth
2929b4d8fa
feat: warn on dirty buffer before store-dependent actions (#83)
* fix(buffer): use `default_category` config for empty placeholder

Problem: The empty-buffer fallback hardcoded the category name `TODO`,
ignoring the user's `default_category` config value (default: `Todo`).

Solution: Read `config.get().default_category` at render time and use
that value for both the header line and `LineMeta` category field.

* fix(diff): match optional checkbox char in `parse_buffer` patterns

Problem: `parse_buffer` used `%[.%]` which requires exactly one
character between brackets, failing to parse empty `[]` checkboxes.

Solution: Change to `%[.?%]` so the character is optional, matching
`[]`, `[ ]`, `[x]`, and `[!]` uniformly.

* fix(init): add `nowait` to buffer keymap opts

Problem: Buffer-local mappings like `!` could be swallowed by Neovim's
operator-pending machinery or by global maps sharing a prefix, since
the keymap opts did not include `nowait`.

Solution: Add `nowait = true` to the shared `opts` table used for all
buffer-local mappings in `_setup_buf_mappings`.

* feat(init): allow `:Pending done` with no args to use cursor line

Problem: `:Pending done` required an explicit task ID, making it
awkward to mark the current task done while inside the pending buffer.

Solution: When called with no ID, `M.done()` reads the cursor row from
`buffer.meta()` to resolve the task ID, erroring if the cursor is not
on a saved task line.

* fix(views): populate `priority` field in `LineMeta`

Problem: Both `category_view` and `priority_view` omitted `priority`
from the `LineMeta` they produced. `apply_extmarks` checks `m.priority`
to decide whether to render the priority icon, so it was always nil,
causing the `[ ]` pending-icon overlay to replace the `[!]` buffer text.

Solution: Add `priority = task.priority` to both LineMeta constructors.

* fix(buffer): keep `_meta` in sync when `open_line` inserts a new line

Problem: `open_line` inserted a buffer line without updating `_meta`,
leaving the entry at that row pointing to the task that was shifted
down. Pressing `<CR>` (toggle_complete) would read the stale meta,
find a real task ID, toggle it, and re-render — destroying the unsaved
new line.

Solution: Insert a `{ type = 'blank' }` sentinel into `_meta` at the
new line's position so buffer-local actions see no task there.

* fix(buffer): use task sentinel in `open_line` for better unsaved-task errors

* feat(init): warn on dirty buffer before store-dependent actions

Problem: `toggle_complete`, `toggle_priority`, `prompt_date`, and
`done` (no-args) all read from `buffer.meta()` which is stale whenever
the buffer has unsaved edits, leading to silent no-ops or acting on the
wrong task.

Solution: Add a `require_saved()` guard that emits a `log.warn` and
returns false when the buffer is modified. Each store-dependent action
calls it before touching meta or the store.

* fix(init): guard `view`, `undo`, and `filter` against dirty buffer

Problem: `toggle_view`, `undo_write`, and `filter` all call
`buffer.render()` which rewrites the buffer from the store, silently
discarding any unsaved edits. The previous `require_saved()` change
missed these three entry points.

Solution: Add `require_saved()` to the `view` and `filter` keymap
lambdas and to `M.undo_write()`. Also guard `M.filter()` directly so
`:Pending filter` from the command line is covered too.

* fix(init): improve dirty-buffer warning message

* fix(init): tighten dirty-buffer warning message
2026-03-06 12:08:10 -05:00
Barrett Ruth
7ad27f6fca
fix: empty buffer placeholder and checkbox pattern fixes (#82)
* fix(buffer): use `default_category` config for empty placeholder

Problem: The empty-buffer fallback hardcoded the category name `TODO`,
ignoring the user's `default_category` config value (default: `Todo`).

Solution: Read `config.get().default_category` at render time and use
that value for both the header line and `LineMeta` category field.

* fix(diff): match optional checkbox char in `parse_buffer` patterns

Problem: `parse_buffer` used `%[.%]` which requires exactly one
character between brackets, failing to parse empty `[]` checkboxes.

Solution: Change to `%[.?%]` so the character is optional, matching
`[]`, `[ ]`, `[x]`, and `[!]` uniformly.
2026-03-06 12:07:52 -05:00
Barrett Ruth
3e8fd0a6a3
refactor(icons): ascii defaults, checkbox overlays, and cleanup (#57)
* docs: remove unnecessary mini.ai recipe from vimdoc

Problem: the `*pending-mini-ai*` section assumed mini.ai intercepts
buffer-local `at`/`it`/`aC`/`iC` mappings, requiring a manual
`vim.b.miniai_config` workaround.

Solution: remove the section. Neovim's keymap resolver already
prioritizes longer buffer-local mappings over mini.ai's global
`a`/`i` handlers — no recipe needed.

* refactor(icons): unify category/header icon and use checkbox overlays

Problem: `header` and `category` were separate icons for the same
concept. The icon overlay replaced `[ ]` with a bare character,
hiding the markdown checkbox syntax. Header format `## ` produced
a double-space with single-char icons.

Solution: merge `header` into `category` (one icon for both header
lines and EOL labels). Overlay renders `[icon]` preserving bracket
syntax. Change header line format from `## ` to `# ` so the
2-char overlay (`# `) maps cleanly.

* ci: remove empty `assets/` placeholder
2026-03-04 18:44:41 -05:00
Barrett Ruth
8c90d0ddd1
refactor: remove file token feature (#50)
* refactor: remove file token feature

Problem: The file metadata token (file:<path>:<line>) was implemented
but is no longer wanted.

Solution: Remove all traces — parse.lua token parsing, diff.lua
reconciliation, views.lua LineMeta field, buffer.lua virtual text and
PendingFile highlight, complete.lua omnifunc trigger, init.lua
goto_file/add_here functions and -file edit token, plugin keymaps
<Plug>(pending-goto-file) and <Plug>(pending-add-here), config.lua
goto_file keymap field, vimdoc FILE TOKEN section, and
spec/file_spec.lua.

* ci: format
2026-02-26 22:41:38 -05:00
Barrett Ruth
0e0568769d
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
2026-02-26 20:03:42 -05:00
Barrett Ruth
64b19360b1
feat(customization): icons config, PendingTab, and demo infrastructure (#46)
* feat(config): add icons table with unicode defaults

* feat(buffer): render icon overlays from config.icons

Problem: status characters ([ ], [x], [!]) and metadata prefixes are
hardcoded literals with no user customization.

Solution: read config.icons in apply_extmarks and apply overlay
extmarks for checkboxes/headers, replace hardcoded recur ↺ with
icons.recur, and prefix due/category virt_text with configurable
icon characters.

* feat(plugin): add PendingTab command and <Plug>(pending-tab)

* docs: add icons config, PendingTab recipes, and demo infrastructure

Problem: icon customization and auto-start workflow are undocumented;
no demo asset exists for the README.

Solution: document pending.Icons in vimdoc with nerd font and ASCII
recipes, add PendingTab to commands and mappings, add open-on-startup
recipe, add demo-init.lua and demo.tape for VHS screenshot generation,
add assets/ directory, add README icons section and demo placeholder.

* ci: format
2026-02-26 19:20:29 -05:00
Barrett Ruth
1748e5caa1
feat(file-token): file: inline metadata token with gf navigation (#45)
* feat(file-token): add file: inline metadata token with gf navigation

Problem: there was no way to link a task to a specific location in a
source file, or to quickly jump from a task to the relevant code.

Solution: add a file:<path>:<line> inline token that stores a relative
file reference in task._extra.file. Virtual text renders basename:line
in a new PendingFile highlight group. A buffer-local gf mapping
(configurable via keymaps.goto_file) opens the file at the given line.
M.add_here() lets users attach the current cursor position to any task
via vim.ui.select(). M.edit() gains -file support to clear the
reference. <Plug>(pending-goto-file) and <Plug>(pending-add-here) are
exposed for custom mappings.

* test(file-token): add parse, diff, views, edit, and navigation tests

Problem: the file: token implementation had no test coverage.

Solution: add spec/file_spec.lua covering parse.body extraction,
malformed token handling, duplicate token stop-parsing, diff
reconciliation (store/update/clear/round-trip), LineMeta population
in both views, :Pending edit -file, and goto_file notify paths for
no-file and unreadable-file cases. All 292 tests pass.

* style: apply stylua formatting

* fix(types): remove empty elseif block, fix file? annotation nullability
2026-02-26 19:12:48 -05:00
Barrett Ruth
dcb6a4781d
feat(filter): oil-like editable filter line (#43)
* feat(filter): oil-like editable filter line with predicate dispatch

Problem: no way to narrow the pending buffer to a subset of tasks
without manual scrolling; filtered-out tasks would be silently deleted
on :w because diff.apply() marks unseen IDs as deleted.

Solution: add a FILTER: line rendered at the top of the buffer when a
filter is active. The line is editable — :w re-parses it and updates
the hidden set. diff.apply() gains a hidden_ids param that prevents
filtered-out tasks from being marked deleted. Predicates: cat:X,
overdue, today, priority (space-separated AND). :Pending filter sets
it programmatically; :Pending filter clear removes it.

* ci: format
2026-02-26 18:29:56 -05:00
Barrett Ruth
302bf8126f
feat: text objects and motions for the pending buffer (#39)
* feat: text objects and motions for the pending buffer

Problem: the pending buffer has action-button mappings but no Vim
grammar. You cannot dat to delete a task, cit to change a description,
or ]] to jump to the next category header.

Solution: add textobj.lua with at/it (a task / inner task), aC/iC
(a category / inner category), ]]/[[ (next/prev header), and ]t/[t
(next/prev task). All text objects work in operator-pending and visual
modes; motions work in normal, visual, and operator-pending. Mappings
are configurable via the keymaps table and exposed as <Plug> mappings.

* fix(textobj): escape Lua pattern hyphen, fix test expectations

Problem: inner_task_range used unescaped '-' in Lua patterns, which
acts as a lazy quantifier instead of matching a literal hyphen. The
metadata-stripping logic also tokenized the full line including the
prefix, so the rebuilt string could never be found after the prefix.
All test column expectations were off by one.

Solution: escape hyphens with %-, rewrite metadata stripping to
tokenize only the description portion after the prefix, and correct
all test assertions to match actual rendered column positions.

* feat(textobj): add debug mode, rename priority view buffer

Problem: the ]] motion reportedly lands one line past the header in
some environments, and ]t/[t may not override Neovim defaults. No
way to diagnose these at runtime. Also, pending://priority is a poor
buffer name for the flat ranked view.

Solution: add a debug config option (vim.g.pending = { debug = true })
that logs meta state, cursor positions, and mapping registration to
:messages at DEBUG level. Rename the buffer from pending://priority to
pending://queue. Internal view identifier stays 'priority'.

* docs: text objects, motions, debug mode, queue view rename

Problem: vimdoc had no documentation for the new text objects, motions,
debug config, or the pending://queue buffer rename.

Solution: add text object and motion tables to the mappings section,
document all eight <Plug> mappings, add debug field to the config
reference, update config example with new keymap defaults, rename
priority view references to queue throughout the vimdoc.

* fix(textobj): use correct config variable, raise log level

Problem: motion keymaps (]], [[, ]t, [t) were never set because
`config.get().debug` referenced an undefined `config` variable,
crashing _setup_buf_mappings before the motion loop. Debug logging
also used vim.log.levels.DEBUG which is filtered by default.

Solution: replace `config` with `cfg` (already in scope) and raise
both debug notify calls from DEBUG to INFO.

* ci: formt
2026-02-26 16:28:58 -05:00
Barrett Ruth
c57cc0845b
feat: time-aware due dates, persistent undo, @return audit (#33)
* fix(plugin): allow command chaining with bar separator

Problem: :Pending|only failed because the command definition lacked the
bar attribute, causing | to be consumed as an argument.

Solution: Add bar = true to nvim_create_user_command so | is treated as
a command separator, matching fugitive's :Git behavior.

* refactor(buffer): remove opinionated window options

Problem: The plugin hardcoded number, relativenumber, wrap, spell,
signcolumn, foldcolumn, and cursorline in set_win_options, overriding
user preferences with no way to opt out.

Solution: Remove all cosmetic window options. Users who want them can
set them in after/ftplugin/pending.lua. Only conceallevel,
concealcursor, and winfixheight remain as functionally required.

* feat: time-aware due dates, persistent undo, @return audit

Problem: Due dates had no time component, the undo stack was lost on
restart and stored in a separate file, and many public functions lacked
required @return annotations.

Solution: Add YYYY-MM-DDThh:mm support across parse, views, recur,
complete, and init with time-aware overdue checks. Merge the undo stack
into the task store JSON so a single file holds all state. Add @return
nil annotations to all 27 void public functions across every module.

* feat(parse): flexible time parsing for @ suffix

Problem: the @HH:MM time suffix required zero-padded 24-hour format,
forcing users to write due:tomorrow@14:00 instead of due:tomorrow@2pm.

Solution: add normalize_time() that accepts bare hours (9, 14),
H:MM (9:30), am/pm (2pm, 9:30am, 12am), and existing HH:MM format,
normalizing all to canonical HH:MM on save.

* feat(complete): add info descriptions to omnifunc items

Problem: completion menu items had no description, making it hard to
distinguish between similar entries like date shorthands and recurrence
patterns.

Solution: return { word, info } tables from date_completions() and
recur_completions(), surfacing human-readable descriptions in the
completion popup.

* ci: format
2026-02-25 20:37:50 -05:00
Barrett Ruth
72dbf037c7
refactor(buffer): remove opinionated window options, fix close (#32)
* fix(plugin): allow command chaining with bar separator

Problem: :Pending|only failed because the command definition lacked the
bar attribute, causing | to be consumed as an argument.

Solution: Add bar = true to nvim_create_user_command so | is treated as
a command separator, matching fugitive's :Git behavior.

* refactor(buffer): remove opinionated window options

Problem: The plugin hardcoded number, relativenumber, wrap, spell,
signcolumn, foldcolumn, and cursorline in set_win_options, overriding
user preferences with no way to opt out.

Solution: Remove all cosmetic window options. Users who want them can
set them in after/ftplugin/pending.lua. Only conceallevel,
concealcursor, and winfixheight remain as functionally required.
2026-02-25 17:34:40 -05:00
Barrett Ruth
b76c680e1f
feat: fix q on close last window (#31)
* fix(plugin): allow command chaining with bar separator

Problem: :Pending|only failed because the command definition lacked the
bar attribute, causing | to be consumed as an argument.

Solution: Add bar = true to nvim_create_user_command so | is treated as
a command separator, matching fugitive's :Git behavior.

* fix: last window
2026-02-25 13:45:42 -05:00
Barrett Ruth
7d93c4bb45
feat: omnifunc completion, recurring tasks, expanded date syntax (#27)
* feat(config): add recur_syntax and someday_date fields

Problem: the plugin needs configuration for the recurrence token name
and the sentinel date used by the `later`/`someday` named dates.

Solution: add `recur_syntax` (default 'rec') and `someday_date`
(default '9999-12-30') to pending.Config and the defaults table.

* feat(parse): expand date vocabulary with named dates

Problem: the date input only supports today, tomorrow, +Nd, and
weekday names, lacking relative offsets like weeks/months, period
boundaries, ordinals, month names, and backdating.

Solution: add yesterday, eod, sow/eow, som/eom, soq/eoq, soy/eoy,
+Nw, +Nm, -Nd, -Nw, ordinals (1st-31st), month names (jan-dec),
and later/someday to resolve_date(). Add tests for all new tokens.

* feat(recur): add recurrence parsing and next-date computation

Problem: the plugin has no concept of recurring tasks, which is
needed for habits and repeating deadlines.

Solution: add recur.lua with parse(), validate(), next_due(),
to_rrule(), and shorthand_list(). Supports named shorthands (daily,
weekdays, weekly, etc.), interval notation (Nd, Nw, Nm, Ny), raw
RRULE passthrough, and ! prefix for completion-based mode. Includes
day-clamping for month/year advancement.

* feat(store): add recur and recur_mode task fields

Problem: the task schema has no fields for storing recurrence rules.

Solution: add recur and recur_mode to the Task class, known_fields,
task_to_table, table_to_task, and the add() signature.

* feat(parse): add rec: inline token parsing

Problem: the buffer parser does not recognize recurrence tokens,
so users cannot set recurrence rules inline.

Solution: add recur_key() helper and rec: token parsing in body()
and command_add(), with ! prefix handling for completion-based mode
and validation via recur.validate().

* feat(diff): propagate recurrence through buffer reconciliation

Problem: the diff layer does not extract or apply recurrence fields,
so rec: tokens written in the buffer are silently ignored on :w.

Solution: add rec and rec_mode to ParsedEntry, extract them in
parse_buffer(), and pass them through create and update paths in
apply().

* feat(init): spawn next task on recurring task completion

Problem: completing a recurring task does not create the next
occurrence, and :Pending add does not pass recurrence fields.

Solution: in toggle_complete(), detect recurrence and spawn a new
pending task with the next due date. Wire rec/rec_mode through the
add() command path.

* feat(views): add recurrence to LineMeta

Problem: LineMeta does not carry recurrence info, so the buffer
layer cannot display recurrence indicators.

Solution: add recur field to LineMeta and populate it in both
category_view() and priority_view().

* feat(buffer): add PendingRecur highlight and recurrence virtual text

Problem: recurring tasks have no visual indicator in the buffer,
and the extmark logic uses a rigid if/elseif chain that does not
compose well with additional virtual text fields.

Solution: add PendingRecur highlight group linking to DiagnosticInfo.
Refactor apply_extmarks() to build virtual text parts dynamically,
appending category, recurrence indicator, and due date as separate
composable segments. Set omnifunc on the pending buffer.

* feat(complete): add omnifunc for cat:, due:, and rec: tokens

Problem: the pending buffer has no completion source, requiring
users to type metadata tokens from memory.

Solution: add complete.lua with an omnifunc that completes cat:
tokens from existing categories, due: tokens from the named date
vocabulary, and rec: tokens from recurrence shorthands.

* docs: document recurrence, expanded dates, omnifunc, new config

Problem: the vimdoc does not cover recurrence, expanded date syntax,
omnifunc completion, or the new config fields.

Solution: add DATE INPUT and RECURRENCE sections, update INLINE
METADATA, COMMANDS, CONFIGURATION, HIGHLIGHT GROUPS, HEALTH CHECK,
and DATA FORMAT. Expand the help popup with recurrence patterns and
new date tokens. Add recurrence validation to healthcheck.

* ci: fix

* fix(recur): resolve LuaLS type errors

Problem: LuaLS reported undefined-field for `_raw` on RecurSpec and
param-type-mismatch for `last_day.day` in `advance_date` because
`osdate.day` infers as `string|integer`.

Solution: Add `_raw` to the RecurSpec class annotation and cast
`last_day.day` to integer in both `math.min` call sites.

* refactor(init): remove help popup, use config-driven keymaps

Problem: Buffer-local keymaps were hardcoded with no way for users to
customize them. The g? help popup duplicated information already in the
vimdoc.

Solution: Remove show_help() and the g? mapping. Refactor
_setup_buf_mappings to read from cfg.keymaps, letting users override or
disable any buffer-local binding via vim.g.pending.

* feat(config): add keymaps table for buffer-local bindings

Problem: Users had no way to customize or disable buffer-local key
bindings in the pending buffer.

Solution: Add a pending.Keymaps class and keymaps field to
pending.Config with defaults for all eight buffer actions. Setting any
key to false disables that binding.

* feat(plugin): add Plug mappings for all buffer actions

Problem: Only five of nine buffer actions had <Plug> mappings, so users
could not bind close, undo, open-line, or open-line-above globally.

Solution: Add <Plug>(pending-close), <Plug>(pending-undo),
<Plug>(pending-open-line), and <Plug>(pending-open-line-above).

* docs: update mappings and config for keymaps and new Plug entries

Problem: Vimdoc still listed g? help popup, lacked documentation for
the four new <Plug> mappings, and had no keymaps config section.

Solution: Remove g? from mappings table, document all nine <Plug>
mappings, add keymaps table to the config example and field reference,
and note that buffer-local keys are configurable.
2026-02-25 13:27:52 -05:00
Barrett Ruth
fbeb0e2bee
feat(buffer): open as bottom-drawer split like fugitive (#23)
* feat(buffer): open as bottom-drawer split like fugitive

Problem: :Pending replaced the current buffer, making it impossible to
view tasks alongside the file being edited. No way to close the drawer
without :q or switching buffers manually.

Solution: open the task buffer in a botright horizontal split instead of
replacing the current buffer. Track the drawer window ID so re-opening
focuses it rather than creating a second split. Set winfixheight so the
drawer keeps its height when other windows open or close. Add q/<Esc>
mappings to close the drawer, and a WinClosed autocmd to clear the
tracked window ID when the user closes it manually. Add drawer_height
config option (default 15).

* fix(buffer): default to natural split height like fugitive

Problem: hardcoded drawer_height=15 was too small and diverged from
fugitive's model. Fugitive issues a plain botright split and lets Vim's
own split rules (equalalways, winheight) divide the available space.

Solution: remove the default height so the split sizes naturally. Only
call nvim_win_set_height when the user sets drawer_height to a positive
value, preserving the opt-in customization path.
2026-02-25 09:34:17 -05:00
Barrett Ruth
5db242a9cf
refactor: adopt markdown-style checkbox buffer format (#20)
* refactor(config): change default category from Inbox to Todo

* refactor(views): adopt markdown checkbox line format

Problem: task lines used an opaque /ID/  [N] prefix format that was
hard to read and inconsistent between category and priority views.
Header lines had no visual marker distinguishing them from tasks.

Solution: render headers as '## Cat', task lines as
'/ID/- [x|!| ] description'. State encoding: [x]=done, [!]=urgent,
[ ]=pending. Both views use the same construction.

* refactor(diff): parse and reconcile markdown checkbox format

Problem: parse_buffer matched the old '  text' indent pattern and
detected headers via '^%S'. Priority was read from a '[N] ' prefix.
apply() never reconciled status changes written into the buffer.

Solution: match '- [.] text' for tasks and '^## ' for headers.
Extract state char to derive priority (! -> 1) and status (x -> done).
apply() now reconciles status from the buffer, setting/clearing 'end'
timestamps — enabling the oil-style edit-checkbox-then-:w workflow.

* refactor(buffer): update syntax, extmarks, and render for checkbox format

Problem: syntax patterns matched the old indent/[N] format; right_align
virtual text produced a broken layout in narrow windows; the done
strikethrough skipped past the '  ' indent leaving '- [x] ' unstyled;
render() added undo history entries so 'u' could undo a re-render.

Solution: update taskHeader/taskLine patterns for '## '/'- [.]'; rename
taskPriority -> taskCheckbox matching '[!]'; switch virt_text_pos to
'eol'; drop the +2 col_start offset so strikethrough covers '- [x] ';
guard nvim_buf_set_lines with undolevels=-1 so renders are not undoable.
Also fix open_line to insert '- [ ] ' and position cursor at col 6.

* refactor(init): replace multi-level priority with binary toggle

Problem: <C-a>/<C-x> overrode Vim's native number increment and the
visual g<C-a>/g<C-x> variants added complexity for marginal value.
toggle_complete() left the cursor on the wrong line after re-render.

Solution: remove change_priority/change_priority_visual; add
toggle_priority() (0<->1) mapped to '!', with cursor-follow after
render matching the pattern already used in priority toggle. Add
cursor-follow to toggle_complete() for the same reason. Update plugin
plugs (priority-up/down -> priority) and add 'due'/'undo' to the
:Pending completion list. Update help text accordingly.

* feat(buffer): reflect current view in buffer name

Problem: no way to tell at a glance which view (category vs priority)
is active — the buffer was always named 'pending://'.

Solution: update the buffer name to 'pending://category' or
'pending://priority' on every render, so the view is visible in
the statusline/tabline without any extra UI.
2026-02-24 23:21:55 -05:00
8f9052bad1 ci: format 2026-02-24 22:32:14 -05:00
7f0bd43b34 feat(buffer): preserve category fold state across re-renders
Problem: pressing :w, toggling priority, or any other operation that
calls buffer.render() reset foldlevel = 99, causing all manually
collapsed category sections to snap back open.

Solution: snapshot which categories are folded (per window) before
nvim_buf_set_lines destroys the fold tree, then restore them after
fold options are re-applied by calling normal! zc on each previously
closed header line. State persists across all render call sites
within a session.
2026-02-24 22:32:14 -05:00
fc4a47a1ec feat(init): multi-level priority with <C-a>/<C-x>
Problem: priority was binary (0 or 1), toggled with !, with no way
to express finer gradations or use Vim's native increment idiom.

Solution: replace toggle_priority with change_priority(delta) which
clamps to floor 0. Display format changes from '! ' to '[N] ' so any
integer level is representable. Parser updated to extract numeric
level from the [N] prefix. Visual g<C-a>/g<C-x> apply the delta to
all tasks in the selection. <Plug>(pending-priority) replaced with
<Plug>(pending-priority-up) and <Plug>(pending-priority-down).
2026-02-24 22:20:18 -05:00
cee0560341 ci: format 2026-02-24 19:52:48 -05:00
3919e3f88f fix(buffer): replace indentexpr with dedicated o/O mappings
Problem: setup_indentexpr always returned 0 because no task line
starts with whitespace (the /ID/ prefix begins with /), so the
return 2 branch was dead code. Pressing o or O opened a blank line
at column 0 with no ID prefix, which the diff parser cannot
recognise as a task.

Solution: remove setup_indentexpr and M.get_indent() entirely; add
M.open_line(above) which inserts a two-space stub line and enters
insert mode at the end so the user types directly into the new task
body. The diff layer already handles lines matching ^  .+ as new
tasks. Add o and O buffer-local mappings in init.lua.
2026-02-24 19:52:48 -05:00
6b14a6bf90 fix(buffer): link highlight groups to colorscheme via default = true
Problem: highlight groups used hardcoded hex colours and a bespoke
hlexists guard, ignoring the user's colorscheme and preventing
overrides from working naturally.

Solution: replace the guard wrapper with direct nvim_set_hl calls
using default = true and link, so each group falls back to a
semantically appropriate built-in group (Title, DiagnosticHint,
DiagnosticError, Comment, DiagnosticWarn) unless the user has
already defined them.
2026-02-24 19:50:27 -05:00
40ebd0ebb2 feat(buffer): add category folds via foldexpr
Problem: category_view had no fold support, making it harder to
focus on one category in large lists.

Solution: add M.get_fold() returning '>1' for headers, '1' for task
lines, and '0' for blanks. M.render() now sets foldmethod=expr
(foldlevel=99) in category view and foldmethod=manual in priority.
2026-02-24 18:44:13 -05:00
Barrett Ruth
f21658f138
feat: overdue highlighting, relative dates, undo write, buffer mappings (#1)
* feat(config): add category_order field

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

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

* feat(parse): add relative date resolution

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

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

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

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

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

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

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

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

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

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

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

* test(views): add views spec

Problem: views.lua had no test coverage.

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

* test(archive): add archive spec

Problem: archive had no test coverage.

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

* docs: add vimdoc

Problem: no :help documentation existed.

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

* ci: format

* fix: resolve lint and type check errors

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

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

* ci: format

* fix: sync

* ci: format
2026-02-24 18:33:07 -05:00
78a275d096
feat: rename 2026-02-24 15:21:44 -05:00
Renamed from lua/todo/buffer.lua (Browse further)