Compare commits

..

28 commits

Author SHA1 Message Date
ddfd4dd826 ci: remove empty assets/ placeholder 2026-03-04 18:42:26 -05:00
530009d830 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.
2026-03-04 18:42:26 -05:00
26b14b6ba8 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.
2026-03-04 18:42:26 -05:00
Barrett Ruth
ee8b660f7c
ci: fix local script (#56)
* refactor(config): default icons to ascii

Problem: default icons used unicode characters (○, ✓, ●, ▸, ·, ↺)
which render poorly in some terminals and font configurations.

Solution: replace defaults with ascii equivalents (-, x, !, >, ., ~).
Users can still override to unicode or nerd font icons via config.

* ci: ignore library type checking

* ci: cleanup ci script
2026-03-04 17:52:25 -05:00
Barrett Ruth
7718ebed42
refactor(config): default icons to ascii (#55)
* refactor(config): default icons to ascii

Problem: default icons used unicode characters (○, ✓, ●, ▸, ·, ↺)
which render poorly in some terminals and font configurations.

Solution: replace defaults with ascii equivalents (-, x, !, >, ., ~).
Users can still override to unicode or nerd font icons via config.

* ci: ignore library type checking
2026-03-04 17:49:30 -05:00
627100eb8c ci: scripts & format 2026-03-04 14:18:46 -05:00
51508285ac ci: nix 2026-03-04 14:07:04 -05:00
Barrett Ruth
a24521ee4e
feat(filter): wire F key and <Plug>(pending-filter) mapping (#53)
* refactor(config): remove legacy gcal top-level config key

Problem: the gcal migration shim silently accepted vim.g.pending = { gcal
= {...} } and copied it to sync.gcal, adding complexity and a deprecated
API surface.

Solution: remove the migration block in config.get(), drop the cfg.gcal
fallback in gcal_config(), delete the two migration tests, and clean up
the vimdoc references. Callers must now use sync.gcal directly.

* ci: fix

* fix(spec): remove duplicate buffer require in complete_spec

* docs(pending): reorganize vimdoc and fix incorrect defaults

Problem: sections were out of logical order — inline metadata appeared
before commands, GCal before its own backend framework, store resolution
duplicated and buried after health check. Two defaults were wrong:
default_category documented as 'Inbox' (should be 'Todo') and the gcal
calendar example used 'Tasks' (should be 'Pendings').

Solution: reorder all 21 sections into onboarding-first flow, add a
CONTENTS table with hyperlinks, fix both incorrect defaults in every
location they appeared, and remove the duplicate STORE RESOLUTION
section.

* feat(filter): wire F key and <Plug>(pending-filter) mapping

Problem: the filter predicate logic, diff guard, _on_write handling,
:Pending filter command, and filter_spec were already implemented, but
there was no buffer-local key to invoke filtering interactively.

Solution: add filter = 'F' to keymaps config and defaults, wire the
filter action in _setup_buf_mappings via vim.ui.input, add
<Plug>(pending-filter), and update the vimdoc (mappings table, Plug
section, config example, and FILTERS section).
2026-02-26 23:25:39 -05:00
Barrett Ruth
e0b192a88a
docs(pending): reorganize vimdoc and fix incorrect defaults (#52)
* refactor(config): remove legacy gcal top-level config key

Problem: the gcal migration shim silently accepted vim.g.pending = { gcal
= {...} } and copied it to sync.gcal, adding complexity and a deprecated
API surface.

Solution: remove the migration block in config.get(), drop the cfg.gcal
fallback in gcal_config(), delete the two migration tests, and clean up
the vimdoc references. Callers must now use sync.gcal directly.

* ci: fix

* fix(spec): remove duplicate buffer require in complete_spec

* docs(pending): reorganize vimdoc and fix incorrect defaults

Problem: sections were out of logical order — inline metadata appeared
before commands, GCal before its own backend framework, store resolution
duplicated and buried after health check. Two defaults were wrong:
default_category documented as 'Inbox' (should be 'Todo') and the gcal
calendar example used 'Tasks' (should be 'Pendings').

Solution: reorder all 21 sections into onboarding-first flow, add a
CONTENTS table with hyperlinks, fix both incorrect defaults in every
location they appeared, and remove the duplicate STORE RESOLUTION
section.
2026-02-26 23:09:05 -05:00
Barrett Ruth
4612960b9a
refactor(config): remove legacy gcal top-level config key (#51)
* refactor(config): remove legacy gcal top-level config key

Problem: the gcal migration shim silently accepted vim.g.pending = { gcal
= {...} } and copied it to sync.gcal, adding complexity and a deprecated
API surface.

Solution: remove the migration block in config.get(), drop the cfg.gcal
fallback in gcal_config(), delete the two migration tests, and clean up
the vimdoc references. Callers must now use sync.gcal directly.

* ci: fix

* fix(spec): remove duplicate buffer require in complete_spec
2026-02-26 22:53:51 -05:00
3ee26112a6 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.
2026-02-26 22:49:11 -05:00
59479ddb0d 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).
2026-02-26 22:49:11 -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
994294393c
docs(textobj): add mini.ai integration recipe to vimdoc (#44)
Problem: users with mini.ai installed find that buffer-local `at`, `it`,
`aC`, `iC` text objects never fire because mini.ai intercepts `a`/`i` as
single-key handlers in operator-pending/visual modes before Neovim's
mapping system can route them to buffer-local maps.

Solution: add a *pending-mini-ai* recipe section to the RECIPES block in
pending.txt. The recipe explains the conflict, describes mini.ai's
custom_textobjects spec (`{ from = {line,col}, to = {line,col} }`), and
shows how to wrap `textobj.inner_task_range` and `textobj.category_bounds`
(the two functions that return positional data) into the shape mini.ai
expects, registered via `vim.b.miniai_config` in a FileType autocmd. Notes
that `aC` cannot be expressed this way due to its linewise selection, and
that the built-in keymaps work fine for users without mini.ai.
2026-02-26 18:30:14 -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
3da23c924a
feat(sync): backend interface + CLI refactor (#42)
* refactor(sync): extract backend interface, adapt gcal module

Problem: :Pending sync hardcodes Google Calendar — M.sync() does
pcall(require, 'pending.sync.gcal') and calls gcal.sync() directly.
The config has a flat gcal field. This prevents adding new sync backends
without modifying init.lua.

Solution: Define a backend interface contract (name, auth, sync, health
fields), refactor :Pending sync to dispatch via require('pending.sync.'
.. backend_name), add sync table to config with legacy gcal migration,
rename gcal.authorize to gcal.auth, add gcal.health for checkhealth,
and add tab completion for backend names and actions.

* docs(sync): update vimdoc for backend interface

Problem: Vimdoc documents :Pending sync as a bare command that pushes
to Google Calendar, with no mention of backends or the sync table config.

Solution: Update :Pending sync section to show {backend} [{action}]
syntax with examples, add SYNC BACKENDS section documenting the interface
contract, update config example to use sync.gcal, document legacy gcal
migration, and update health check description.

* test(sync): add backend dispatch tests

Problem: No test coverage for sync dispatch logic, config migration,
or gcal module interface conformance.

Solution: Add spec/sync_spec.lua with tests for: bare sync errors,
empty backend errors, unknown backend errors, unknown action errors,
default-to-sync routing, explicit sync/auth routing, legacy gcal config
migration, explicit sync.gcal precedence, and gcal module interface
fields (name, auth, sync, health).
2026-02-26 17:59:04 -05:00
Barrett Ruth
8d3d21b330
feat: :Pending edit command for CLI metadata editing (#41)
* feat: :Pending edit command for CLI metadata editing

Problem: editing task metadata (due date, category, priority,
recurrence) requires opening the buffer and editing inline. No way
to make quick metadata changes from the command line.

Solution: add :Pending edit {id} [operations...] command that applies
metadata changes by numeric task ID. Supports due:<date>, cat:<name>,
rec:<pattern>, +!, -!, -due, -cat, -rec operations with full date
vocabulary and recurrence validation. Pushes to undo stack, re-renders
the buffer if open, and provides feedback messages. Tab completion for
IDs, field names, date vocabulary, categories, and recurrence patterns.
Also fixes store.update() to properly clear fields set to vim.NIL.

* ci: formt
2026-02-26 16:34:07 -05:00
Barrett Ruth
e62e09f609
feat: statusline API, counts, and PendingStatusChanged event (#40)
Problem: no way to know about overdue or due-today tasks without
opening :Pending. No ambient awareness for statusline plugins.

Solution: add counts(), statusline(), and has_due() public API
functions backed by a module-local cache that recomputes after every
store.save() and store.load(). Fire a User PendingStatusChanged event
on every recompute. Extract is_overdue() and is_today() from duplicate
locals into parse.lua as public functions. Refactor views.lua and
init.lua to use the shared date logic. Add vimdoc API section and
integration recipes for lualine, heirline, manual statusline, startup
notification, and event-driven refresh.
2026-02-26 16:30:06 -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
379e281ecd
fix(plugin): allow command chaining with bar separator (#29)
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.
2026-02-25 13:40:36 -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
6911c091f6
doc: minify readme (#24)
* doc: minify readme

* ci: format
2026-02-25 09:40:06 -05:00
33 changed files with 5532 additions and 683 deletions

View file

@ -2,7 +2,14 @@
"runtime.version": "LuaJIT",
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
"diagnostics.globals": ["vim", "jit"],
"workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
"diagnostics.libraryFiles": "Disable",
"workspace.library": [
"$VIMRUNTIME/lua",
"${3rd}/luv/library",
"${3rd}/busted/library",
"${3rd}/luassert/library"
],
"workspace.checkThirdParty": false,
"workspace.ignoreDir": [".direnv"],
"completion.callSnippet": "Replace"
}

View file

@ -2,7 +2,7 @@
Edit tasks like text. `:w` saves them.
<!-- insert preview -->
![demo](assets/demo.gif)
## Requirements
@ -24,6 +24,21 @@ luarocks install pending.nvim
:help pending.nvim
```
## Icons
All display characters are configurable. Defaults produce markdown-style checkboxes (`[ ]`, `[x]`, `[!]`):
```lua
vim.g.pending = {
icons = {
pending = ' ', done = 'x', priority = '!',
due = '.', recur = '~', category = '#',
},
}
```
See `:help pending.Icons` for nerd font examples.
## Acknowledgements
- [dooing](https://github.com/atiladefreitas/dooing)

View file

@ -30,21 +30,48 @@ 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
- Multi-level undo (up to 20 `:w` saves, session-only)
- Inline metadata syntax: `due:`, `cat:`, and `rec:` tokens parsed on `:w`
- Relative date input: `today`, `tomorrow`, `+Nd`, `+Nw`, `+Nm`, weekday
names, month names, ordinals, and more
- Recurring tasks with automatic next-date spawning on completion
- Two views: category (default) and queue (priority-sorted flat list)
- Multi-level undo (up to 20 `:w` saves, persisted across sessions)
- Quick-add from the command line with `:Pending add`
- Quickfix list of overdue/due-today tasks via `:Pending due`
- Foldable category sections (`zc`/`zo`) in category view
- Omnifunc completion for `cat:`, `due:`, and `rec:` tokens (`<C-x><C-o>`)
- Google Calendar one-way push via OAuth PKCE
==============================================================================
CONTENTS *pending-contents*
1. Introduction ............................................. |pending.nvim|
2. Requirements ..................................... |pending-requirements|
3. Install ............................................... |pending-install|
4. Usage ................................................... |pending-usage|
5. Commands .............................................. |pending-commands|
6. Mappings .............................................. |pending-mappings|
7. Views ................................................... |pending-views|
8. Filters ............................................... |pending-filters|
9. Inline Metadata ....................................... |pending-metadata|
10. Date Input .............................................. |pending-dates|
11. Recurrence ......................................... |pending-recurrence|
12. Configuration ........................................... |pending-config|
13. Store Resolution .......................... |pending-store-resolution|
14. Highlight Groups .................................... |pending-highlights|
15. Lua API ................................................... |pending-api|
16. Recipes ............................................... |pending-recipes|
17. Sync Backends ................................... |pending-sync-backend|
18. Google Calendar .......................................... |pending-gcal|
19. Data Format .............................................. |pending-data|
20. Health Check ........................................... |pending-health|
==============================================================================
REQUIREMENTS *pending-requirements*
- Neovim 0.10+
- No external dependencies for local use
- `curl` and `openssl` are required for Google Calendar sync
- `curl` and `openssl` are required for the `gcal` sync backend
==============================================================================
INSTALL *pending-install*
@ -86,39 +113,6 @@ persists across window switches; reopening with `:Pending` focuses the
existing window if one is open. The buffer is automatically reloaded from
disk when entered unmodified.
==============================================================================
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*
@ -135,6 +129,7 @@ COMMANDS *pending-commands*
:Pending add Buy groceries due:2026-03-15
:Pending add School: Submit homework
:Pending add Errands: Pick up dry cleaning due:fri
:Pending add Work: standup due:tomorrow rec:weekdays
<
If the buffer is currently open it is re-rendered after the add.
@ -152,16 +147,85 @@ COMMANDS *pending-commands*
Open the list with |:copen| to navigate to each task's category.
*: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 sync {backend} [{action}]
Run a sync action against a named backend. {backend} is required — bare
`:Pending sync` prints a usage message. {action} defaults to `sync`
when omitted. Each backend lives at `lua/pending/sync/<name>.lua`.
Examples: >vim
:Pending sync gcal " runs gcal.sync()
:Pending sync gcal auth " runs gcal.auth()
:Pending sync gcal sync " explicit sync (same as bare)
<
Tab completion after `:Pending sync ` lists discovered backends.
Tab completion after `:Pending sync gcal ` lists available actions.
Built-in backends: ~
`gcal` Google Calendar one-way push. See |pending-gcal|.
*:Pending-filter*
:Pending filter {predicates}
Apply a filter to the task buffer. {predicates} is a space-separated list
of one or more predicate tokens. Only tasks matching all predicates (AND
semantics) are shown. Hidden tasks are not deleted — they are preserved in
the store and reappear when the filter is cleared. >vim
:Pending filter cat:Work
:Pending filter overdue
:Pending filter cat:Work overdue
:Pending filter priority
:Pending filter clear
<
When a filter is active the buffer's first line shows: >
FILTER: cat:Work overdue
<
The user can edit this line inline and `:w` to change the active filter.
Deleting the `FILTER:` line entirely and saving clears the filter.
`:Pending filter clear` also clears the filter programmatically.
Tab completion after `:Pending filter ` lists available predicates and
category values. Already-used predicates are excluded from completions.
See |pending-filters| for the full list of supported predicates.
*:Pending-edit*
:Pending edit {id} [{operations}]
Edit metadata on an existing task without opening the buffer. {id} is the
numeric task ID. One or more operations follow: >vim
:Pending edit 5 due:tomorrow cat:Work +!
:Pending edit 5 -due -cat -rec
:Pending edit 5 rec:!weekly due:fri
<
Operations: ~
`due:<date>` Set due date (accepts all |pending-dates| vocabulary).
`cat:<name>` Set category.
`rec:<pattern>` Set recurrence (prefix `!` for completion-based).
`+!` Add priority flag.
`-!` Remove priority flag.
`-due` Clear due date.
`-cat` Clear category.
`-rec` Clear recurrence.
Tab completion is available for IDs, field names, date values, categories,
and recurrence patterns.
*:Pending-undo*
:Pending undo
Undo the last `:w` save, restoring the task store to its previous state.
Equivalent to the `U` buffer-local key (see |pending-mappings|). Up to 20
levels of undo are retained per session.
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.
==============================================================================
MAPPINGS *pending-mappings*
@ -169,27 +233,63 @@ 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: ~
Buffer-local keys are configured via the `keymaps` table in |pending-config|.
The defaults are shown below. Set any key to `false` to disable it.
Default 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
`q` Close the task buffer (`close`)
`<CR>` Toggle complete / uncomplete (`toggle`)
`!` Toggle the priority flag (`priority`)
`D` Prompt for a due date (`date`)
`F` Prompt for filter predicates (`filter`)
`<Tab>` Switch between category / queue view (`view`)
`U` Undo the last `:w` save (`undo`)
`o` Insert a new task line below (`open_line`)
`O` Insert a new task line above (`open_line_above`)
`zc` Fold the current category section (category view only)
`zo` Unfold the current category section (category view only)
`o` and `O` are overridden to insert a correctly-formatted blank task line
at the position below or above the cursor rather than using standard Vim
indentation. `dd`, `p`, `P`, and `:w` work as expected.
Text objects (operator-pending and visual): ~
Key Action ~
------- ------------------------------------------------
`at` Select the current task line (`a_task`)
`it` Select the task description only (`i_task`)
`aC` Select a category: header + tasks + blanks (`a_category`)
`iC` Select inner category: tasks only (`i_category`)
`at` supports count: `d3at` deletes three consecutive tasks. `it` selects
the description text between the checkbox prefix and trailing metadata
tokens (`due:`, `cat:`, `rec:`), making `cit` the natural way to retype a
task description without touching its metadata.
`aC` and `iC` are no-ops in the queue view (no headers to delimit).
Motions (normal, visual, operator-pending): ~
Key Action ~
------- ------------------------------------------------
`]]` Jump to the next category header (`next_header`)
`[[` Jump to the previous category header (`prev_header`)
`]t` Jump to the next task line (`next_task`)
`[t` Jump to the previous task line (`prev_task`)
All motions support count: `3]]` jumps three headers forward. `]]` and
`[[` are no-ops in the queue view. `]t` and `[t` work in both views.
`dd`, `p`, `P`, and `:w` work as standard Vim operations.
*<Plug>(pending-open)*
<Plug>(pending-open)
Open the task buffer. Maps to |:Pending| with no arguments.
*<Plug>(pending-close)*
<Plug>(pending-close)
Close the task buffer window.
*<Plug>(pending-toggle)*
<Plug>(pending-toggle)
Toggle complete / uncomplete for the task under the cursor.
@ -206,6 +306,57 @@ indentation. `dd`, `p`, `P`, and `:w` work as expected.
<Plug>(pending-view)
Switch between category view and priority view.
*<Plug>(pending-undo)*
<Plug>(pending-undo)
Undo the last `:w` save.
*<Plug>(pending-filter)*
<Plug>(pending-filter)
Prompt for filter predicates via |vim.ui.input|.
*<Plug>(pending-open-line)*
<Plug>(pending-open-line)
Insert a correctly-formatted blank task line below the cursor.
*<Plug>(pending-open-line-above)*
<Plug>(pending-open-line-above)
Insert a correctly-formatted blank task line above the cursor.
*<Plug>(pending-a-task)*
<Plug>(pending-a-task)
Select the current task line (linewise). Supports count.
*<Plug>(pending-i-task)*
<Plug>(pending-i-task)
Select the task description text (characterwise).
*<Plug>(pending-a-category)*
<Plug>(pending-a-category)
Select a full category section: header, tasks, and surrounding blanks.
*<Plug>(pending-i-category)*
<Plug>(pending-i-category)
Select tasks within a category, excluding the header and blanks.
*<Plug>(pending-next-header)*
<Plug>(pending-next-header)
Jump to the next category header. Supports count.
*<Plug>(pending-prev-header)*
<Plug>(pending-prev-header)
Jump to the previous category header. Supports count.
*<Plug>(pending-next-task)*
<Plug>(pending-next-task)
Jump to the next task line, skipping headers and blanks.
*<Plug>(pending-prev-task)*
<Plug>(pending-prev-task)
Jump to the previous task line, skipping headers and blanks.
<Plug>(pending-tab) *<Plug>(pending-tab)*
Open the task buffer in a new tab. See |:PendingTab|.
Example configuration: >lua
vim.keymap.set('n', '<leader>t', '<Plug>(pending-open)')
vim.keymap.set('n', '<leader>T', '<Plug>(pending-toggle)')
@ -224,12 +375,182 @@ Category view (default): ~ *pending-view-category*
first within each group. Category sections are foldable with `zc` and
`zo`.
Priority view: ~ *pending-view-priority*
Queue view: ~ *pending-view-queue*
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.
across categories. The buffer is named `pending://queue`.
==============================================================================
FILTERS *pending-filters*
Filters narrow the task buffer to a subset of tasks without deleting any data.
Hidden tasks are preserved in the store and reappear when the filter is
cleared. Filter state is session-local — it does not persist across Neovim
restarts.
Set a filter with |:Pending-filter|, the `F` buffer key, or by editing the
`FILTER:` line: >vim
:Pending filter cat:Work overdue
<
Multiple predicates are separated by spaces and combined with AND logic — a
task must match every predicate to be shown.
Available predicates: ~
`cat:X` Show only tasks whose category is exactly `X`. Tasks with no
category (assigned to `default_category`) are hidden unless
`default_category` matches `X`.
`overdue` Show only pending tasks with a due date strictly before today.
`today` Show only pending tasks with a due date equal to today.
`priority` Show only tasks with priority > 0 (the `!` marker).
`clear` Special value for |:Pending-filter| — clears the active filter
and shows all tasks.
FILTER: line: ~ *pending-filter-line*
When a filter is active, the first line of the task buffer is: >
FILTER: cat:Work overdue
<
This line is editable. Write the buffer with `:w` to apply the updated
predicates. Deleting the `FILTER:` line and saving clears the filter. The
line is highlighted with |PendingFilter| and does not appear in the stored
task data.
==============================================================================
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:<name>` Resolve a named date (see |pending-dates| below).
`cat:Name` Move the task to the named category on save.
`rec:<pattern>` Set a recurrence rule (see |pending-recurrence|).
The token name for due dates defaults to `due` and is configurable via
`date_syntax` in |pending-config|. The token name for recurrence defaults to
`rec` and is configurable via `recur_syntax`.
Example: >
Buy milk due:2026-03-15 cat:Errands
Take out trash due:monday rec:weekly
<
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:`, one
`cat:`, and one `rec:` per task line are consumed.
Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types.
In insert mode, type the token prefix and press `<C-x><C-o>` to see
suggestions.
==============================================================================
DATE INPUT *pending-dates*
Named dates can be used anywhere a date is accepted: the `due:` inline
token, the `D` prompt, and `:Pending add`.
Token Resolves to ~
----- -----------
`today` Today's date
`tomorrow` Tomorrow's date
`yesterday` Yesterday's date
`eod` Today (end of day semantics)
`+Nd` N days from today (e.g. `+3d`)
`+Nw` N weeks from today (e.g. `+2w`)
`+Nm` N months from today (e.g. `+1m`)
`-Nd` N days ago (e.g. `-2d`)
`-Nw` N weeks ago (e.g. `-1w`)
`mon``sun` Next occurrence of that weekday
`jan``dec` 1st of next occurrence of that month
`1st``31st` Next occurrence of that day-of-month
`sow` / `eow` Monday / Sunday of current week
`som` / `eom` First / last day of current month
`soq` / `eoq` First / last day of current quarter
`soy` / `eoy` January 1 / December 31 of current year
`later` / `someday` Sentinel date (default: `9999-12-30`)
Time suffix: ~ *pending-dates-time*
Any named date or absolute date accepts an `@` time suffix. Supported
formats: `HH:MM` (24h), `H:MM`, bare hour (`9`, `14`), and am/pm
(`2pm`, `9:30am`, `12am`). All forms are normalized to `HH:MM` on save. >
due:tomorrow@2pm " tomorrow at 14:00
due:fri@9 " next Friday at 09:00
due:+1w@17:00 " one week from today at 17:00
due:tomorrow@9:30am " tomorrow at 09:30
due:2026-03-15@08:00 " absolute date with time
due:2026-03-15T14:30 " ISO 8601 datetime (also accepted)
<
Tasks with a time component are not considered overdue until after the
specified time. The time is displayed alongside the date in virtual text
and preserved across recurrence advances.
==============================================================================
RECURRENCE *pending-recurrence*
Tasks can recur on a schedule. Add a `rec:` token to set recurrence: >
- [ ] Take out trash due:monday rec:weekly
- [ ] Pay rent due:2026-03-01 rec:monthly
- [ ] Standup due:tomorrow rec:weekdays
<
When a recurring task is marked done with `<CR>`:
1. The current task stays as done (preserving history).
2. A new pending task is created with the same description, category,
priority, and recurrence — with the due date advanced to the next
occurrence.
Shorthand patterns: ~
Pattern Meaning ~
------- -------
`daily` Every day
`weekdays` Monday through Friday
`weekly` Every week
`biweekly` Every 2 weeks (alias: `2w`)
`monthly` Every month
`quarterly` Every 3 months (alias: `3m`)
`yearly` Every year (alias: `annual`)
`Nd` Every N days (e.g. `3d`)
`Nw` Every N weeks (e.g. `2w`)
`Nm` Every N months (e.g. `6m`)
`Ny` Every N years (e.g. `2y`)
For patterns the shorthand cannot express, use a raw RRULE fragment: >
rec:FREQ=MONTHLY;BYDAY=1MO
<
Completion-based recurrence: ~ *pending-recur-completion*
By default, recurrence is schedule-based: the next due date advances from the
original schedule, skipping to the next future occurrence. Prefix the pattern
with `!` for completion-based mode, where the next due date advances from the
completion date: >
rec:!weekly
<
Schedule-based is like org-mode `++`; completion-based is like `.+`.
Google Calendar: ~
Recurrence patterns map directly to iCalendar RRULE strings for future GCal
sync support. Completion-based recurrence cannot be synced (it is inherently
local).
==============================================================================
CONFIGURATION *pending-config*
@ -239,13 +560,36 @@ loads: >lua
vim.g.pending = {
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
default_view = 'category',
default_category = 'Inbox',
default_category = 'Todo',
date_format = '%b %d',
date_syntax = 'due',
recur_syntax = 'rec',
someday_date = '9999-12-30',
category_order = {},
gcal = {
calendar = 'Tasks',
credentials_path = '/path/to/client_secret.json',
keymaps = {
close = 'q',
toggle = '<CR>',
view = '<Tab>',
priority = '!',
date = 'D',
undo = 'U',
filter = 'F',
open_line = 'o',
open_line_above = 'O',
a_task = 'at',
i_task = 'it',
a_category = 'aC',
i_category = 'iC',
next_header = ']]',
prev_header = '[[',
next_task = ']t',
prev_task = '[t',
},
sync = {
gcal = {
calendar = 'Pendings',
credentials_path = '/path/to/client_secret.json',
},
},
}
<
@ -255,15 +599,17 @@ 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
first time in a session.
{default_category} (string, default: 'Inbox')
{default_category} (string, default: 'Todo')
Category assigned to new tasks when no `cat:` token
is present and no `Category: ` prefix is used with
`:Pending add`.
@ -278,70 +624,77 @@ Fields: ~
this to use a different keyword, for example `'by'`
to write `by:2026-03-15` instead of `due:2026-03-15`.
{recur_syntax} (string, default: 'rec')
The token name for inline recurrence metadata. Change
this to use a different keyword, for example
`'repeat'` to write `repeat:weekly`.
{someday_date} (string, default: '9999-12-30')
The date that `later` and `someday` resolve to. This
acts as a "no date" sentinel for GTD-style workflows.
{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.
{keymaps} (table, default: see below) *pending.Keymaps*
Buffer-local key bindings. Each field maps an action
name to a key string. Set a field to `false` to
disable that binding. Unset fields use the default.
See |pending-mappings| for the full list of actions
and their default keys.
==============================================================================
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',
},
}
{debug} (boolean, default: false)
Enable diagnostic logging. When `true`, textobj
motions, mapping registration, and cursor jumps
emit messages at `vim.log.levels.DEBUG`. Use
|:messages| to inspect the output. Useful for
diagnosing keymap conflicts (e.g. `]t` colliding
with Neovim defaults) or motion misbehavior.
Example: >lua
vim.g.pending = { debug = true }
<
*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.
{sync} (table, default: {}) *pending.SyncConfig*
Sync backend configuration. Each key is a backend
name and the value is the backend-specific config
table. Currently only `gcal` is built-in.
{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.
{icons} (table) *pending.Icons*
Icon characters displayed in the buffer. The
{pending}, {done}, and {priority} characters
appear inside brackets (`[icon]`) as an overlay
on the checkbox. The {category} character
prefixes both header lines and EOL category
labels. Fields:
{pending} Pending task character. Default: ' '
{done} Done task character. Default: 'x'
{priority} Priority task character. Default: '!'
{due} Due date prefix. Default: '.'
{recur} Recurrence prefix. Default: '~'
{category} Category prefix. Default: '#'
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.
==============================================================================
STORE RESOLUTION *pending-store-resolution*
`: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.
When pending.nvim opens the task buffer it resolves which store file to use:
A summary notification is shown after sync: `created: N, updated: N,
deleted: N`.
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.
==============================================================================
HIGHLIGHT GROUPS *pending-highlights*
@ -369,6 +722,16 @@ PendingDone Applied to the text of completed tasks.
*PendingPriority*
PendingPriority Applied to the `! ` priority marker on priority tasks.
Default: links to `DiagnosticWarn`.
*PendingRecur*
PendingRecur Applied to the recurrence indicator virtual text shown
alongside due dates for recurring tasks.
Default: links to `DiagnosticInfo`.
*PendingFilter*
PendingFilter Applied to the `FILTER:` header line shown at the top of
the buffer when a filter is active.
Default: links to `DiagnosticWarn`.
To override a group in your colorscheme or config: >lua
@ -376,25 +739,218 @@ To override a group in your colorscheme or config: >lua
<
==============================================================================
HEALTH CHECK *pending-health*
LUA API *pending-api*
Run |:checkhealth| pending to verify your setup: >vim
:checkhealth pending
The following functions are available on `require('pending')` for use in
statuslines, autocmds, and other integrations.
*pending.counts()*
pending.counts()
Returns a table of current task counts: >lua
{
overdue = 2, -- pending tasks past their due date/time
today = 1, -- pending tasks due today (not yet overdue)
pending = 10, -- total pending tasks (all statuses)
priority = 3, -- pending tasks with priority > 0
next_due = "2026-03-01", -- earliest future due date, or nil
}
<
The counts are read from a module-local cache that is invalidated on every
`:w`, toggle, date change, archive, undo, and sync. The first call triggers
a lazy `store.load()` if the store has not been loaded yet.
Done, deleted, and `someday` sentinel-dated tasks are excluded from the
`overdue` and `today` counts. The `someday` sentinel is the value of
`someday_date` in |pending-config| (default `9999-12-30`).
*pending.statusline()*
pending.statusline()
Returns a pre-formatted string suitable for embedding in a statusline:
- `"2 overdue, 1 today"` when both overdue and today counts are non-zero
- `"2 overdue"` when only overdue
- `"1 today"` when only today
- `""` (empty string) when nothing is actionable
*pending.has_due()*
pending.has_due()
Returns `true` when `overdue > 0` or `today > 0`. Useful as a conditional
for statusline components that should only render when tasks need attention.
*PendingStatusChanged*
PendingStatusChanged
A |User| autocmd event fired after every count recomputation. Use this to
trigger statusline refreshes or notifications: >lua
vim.api.nvim_create_autocmd('User', {
pattern = 'PendingStatusChanged',
callback = function()
vim.cmd.redrawstatus()
end,
})
<
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)
==============================================================================
RECIPES *pending-recipes*
Configure blink.cmp to use pending.nvim's omnifunc as a completion source: >lua
require('blink.cmp').setup({
sources = {
per_filetype = {
pending = { 'omni', 'buffer' },
},
},
})
<
Lualine integration: >lua
require('lualine').setup({
sections = {
lualine_x = {
{
function() return require('pending').statusline() end,
cond = function() return require('pending').has_due() end,
},
},
},
})
<
Heirline integration: >lua
local Pending = {
condition = function() return require('pending').has_due() end,
provider = function() return require('pending').statusline() end,
}
<
Manual statusline: >vim
set statusline+=%{%v:lua.require('pending').statusline()%}
<
Startup notification: >lua
vim.api.nvim_create_autocmd('User', {
pattern = 'PendingStatusChanged',
once = true,
callback = function()
local c = require('pending').counts()
if c.overdue > 0 then
vim.notify(c.overdue .. ' overdue task(s)')
end
end,
})
<
Event-driven statusline refresh: >lua
vim.api.nvim_create_autocmd('User', {
pattern = 'PendingStatusChanged',
callback = function()
vim.cmd.redrawstatus()
end,
})
<
Nerd font icons: >lua
vim.g.pending = {
icons = {
due = '',
recur = '󰁯',
category = '',
},
}
<
Open tasks in a new tab on startup: >lua
vim.api.nvim_create_autocmd('VimEnter', {
callback = function()
vim.cmd.PendingTab()
end,
})
<
==============================================================================
SYNC BACKENDS *pending-sync-backend*
Sync backends are Lua modules under `lua/pending/sync/<name>.lua`. Each
module returns a table conforming to the backend interface: >lua
---@class pending.SyncBackend
---@field name string
---@field auth fun(): nil
---@field sync fun(): nil
---@field health? fun(): nil
<
Required fields: ~
{name} Backend identifier (matches the filename).
{sync} Main sync action. Called by `:Pending sync <name>`.
{auth} Authorization flow. Called by `:Pending sync <name> auth`.
Optional fields: ~
{health} Called by `:checkhealth pending` to report backend-specific
diagnostics (e.g. checking for external tools).
Backend-specific configuration goes under `sync.<name>` in |pending-config|.
==============================================================================
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 = {
sync = {
gcal = {
calendar = 'Pendings',
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 gcal` 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 gcal` 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`.
==============================================================================
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.
@ -414,6 +970,8 @@ Task fields: ~
{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.
{recur} (string) Recurrence shorthand (e.g. `weekly`), or absent.
{recur_mode} (string) `'scheduled'` or `'completion'`, 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.
@ -429,4 +987,10 @@ 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:
HEALTH CHECK *pending-health*
Run |:checkhealth| pending to verify your setup: >vim
:checkhealth pending
<
==============================================================================

View file

@ -13,9 +13,12 @@
...
}:
let
forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
forEachSystem =
f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
in
{
formatter = forEachSystem (pkgs: pkgs.nixfmt-tree);
devShells = forEachSystem (pkgs: {
default = pkgs.mkShell {
packages = [

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?
@ -16,6 +18,10 @@ local current_view = nil
local _meta = {}
---@type table<integer, table<string, boolean>>
local _fold_state = {}
---@type string[]
local _filter_predicates = {}
---@type table<integer, true>
local _hidden_ids = {}
---@return pending.LineMeta[]
function M.meta()
@ -37,12 +43,50 @@ 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
end
---@return table<integer, true>
function M.hidden_ids()
return _hidden_ids
end
---@param predicates string[]
---@param hidden table<integer, true>
---@return nil
function M.set_filter(predicates, hidden)
_filter_predicates = predicates
_hidden_ids = hidden
end
---@return nil
function M.clear_winid()
task_winid = nil
end
---@return nil
function M.close()
if task_winid and vim.api.nvim_win_is_valid(task_winid) then
if not task_winid or not vim.api.nvim_win_is_valid(task_winid) then
task_winid = nil
return
end
local wins = vim.api.nvim_list_wins()
if #wins == 1 then
vim.cmd.enew()
else
vim.api.nvim_win_close(task_winid, false)
end
task_winid = nil
@ -55,19 +99,13 @@ local function set_buf_options(bufnr)
vim.bo[bufnr].swapfile = false
vim.bo[bufnr].filetype = 'pending'
vim.bo[bufnr].modifiable = true
vim.bo[bufnr].omnifunc = 'v:lua.require("pending.complete").omnifunc'
end
---@param winid integer
local function set_win_options(winid)
vim.wo[winid].conceallevel = 3
vim.wo[winid].concealcursor = 'nvic'
vim.wo[winid].wrap = false
vim.wo[winid].number = false
vim.wo[winid].relativenumber = false
vim.wo[winid].signcolumn = 'no'
vim.wo[winid].foldcolumn = '0'
vim.wo[winid].spell = false
vim.wo[winid].cursorline = true
vim.wo[winid].winfixheight = true
end
@ -77,7 +115,7 @@ local function setup_syntax(bufnr)
vim.cmd([[
syntax clear
syntax match taskId /^\/\d\+\// conceal
syntax match taskHeader /^## .*$/ contains=taskId
syntax match taskHeader /^# .*$/ contains=taskId
syntax match taskCheckbox /\[!\]/ contained containedin=taskLine
syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox
]])
@ -85,6 +123,7 @@ local function setup_syntax(bufnr)
end
---@param above boolean
---@return nil
function M.open_line(above)
local bufnr = task_bufnr
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
@ -117,29 +156,34 @@ end
---@param bufnr integer
---@param line_meta pending.LineMeta[]
local function apply_extmarks(bufnr, line_meta)
local icons = config.get().icons
vim.api.nvim_buf_clear_namespace(bufnr, task_ns, 0, -1)
for i, m in ipairs(line_meta) do
local row = i - 1
if m.type == 'task' then
if m.type == 'filter' then
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
end_col = #line,
hl_group = 'PendingFilter',
})
elseif m.type == 'task' 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 } }
local virt_parts = {}
if m.show_category and m.category then
table.insert(virt_parts, { icons.category .. ' ' .. m.category, 'PendingHeader' })
end
if m.recur then
table.insert(virt_parts, { icons.recur .. ' ' .. m.recur, 'PendingRecur' })
end
if m.due then
table.insert(virt_parts, { icons.due .. ' ' .. m.due, due_hl })
end
if #virt_parts > 0 then
for p = 1, #virt_parts - 1 do
virt_parts[p][1] = virt_parts[p][1] .. ' '
end
if virt_text then
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
virt_text = virt_text,
virt_text_pos = 'eol',
})
end
elseif m.due then
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
virt_text = { { m.due, due_hl } },
virt_text = virt_parts,
virt_text_pos = 'eol',
})
end
@ -151,12 +195,32 @@ local function apply_extmarks(bufnr, line_meta)
hl_group = 'PendingDone',
})
end
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
local bracket_col = (line:find('%[') or 1) - 1
local icon, icon_hl
if m.status == 'done' then
icon, icon_hl = icons.done, 'PendingDone'
elseif m.priority and m.priority > 0 then
icon, icon_hl = icons.priority, 'PendingPriority'
else
icon, icon_hl = icons.pending, 'Normal'
end
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, bracket_col, {
virt_text = { { '[' .. icon .. ']', icon_hl } },
virt_text_pos = 'overlay',
priority = 100,
})
elseif m.type == 'header' then
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
end_col = #line,
hl_group = 'PendingHeader',
})
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
virt_text = { { icons.category .. ' ', 'PendingHeader' } },
virt_text_pos = 'overlay',
priority = 100,
})
end
end
end
@ -167,6 +231,8 @@ local function setup_highlights()
vim.api.nvim_set_hl(0, 'PendingOverdue', { link = 'DiagnosticError', default = true })
vim.api.nvim_set_hl(0, 'PendingDone', { link = 'Comment', default = true })
vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true })
vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true })
vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true })
end
local function snapshot_folds(bufnr)
@ -212,6 +278,7 @@ local function restore_folds(bufnr)
end
---@param bufnr? integer
---@return nil
function M.render(bufnr)
bufnr = bufnr or task_bufnr
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
@ -219,8 +286,15 @@ function M.render(bufnr)
end
current_view = current_view or config.get().default_view
vim.api.nvim_buf_set_name(bufnr, 'pending://' .. current_view)
local tasks = store.active_tasks()
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 and _store:active_tasks() or {}
local tasks = {}
for _, task in ipairs(all_tasks) do
if not _hidden_ids[task.id] then
table.insert(tasks, task)
end
end
local lines, line_meta
if current_view == 'priority' then
@ -229,6 +303,11 @@ function M.render(bufnr)
lines, line_meta = views.category_view(tasks)
end
if #_filter_predicates > 0 then
table.insert(lines, 1, 'FILTER: ' .. table.concat(_filter_predicates, ' '))
table.insert(line_meta, 1, { type = 'filter' })
end
_meta = line_meta
snapshot_folds(bufnr)
@ -256,6 +335,7 @@ function M.render(bufnr)
restore_folds(bufnr)
end
---@return nil
function M.toggle_view()
if current_view == 'category' then
current_view = 'priority'
@ -268,7 +348,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)

173
lua/pending/complete.lua Normal file
View file

@ -0,0 +1,173 @@
local config = require('pending.config')
---@class pending.complete
local M = {}
---@return string
local function date_key()
return config.get().date_syntax or 'due'
end
---@return string
local function recur_key()
return config.get().recur_syntax or 'rec'
end
---@return string[]
local function get_categories()
local s = require('pending.buffer').store()
if not s then
return {}
end
local seen = {}
local result = {}
for _, task in ipairs(s:active_tasks()) do
local cat = task.category
if cat and not seen[cat] then
seen[cat] = true
table.insert(result, cat)
end
end
table.sort(result)
return result
end
---@return { word: string, info: string }[]
local function date_completions()
return {
{ word = 'today', info = "Today's date" },
{ word = 'tomorrow', info = "Tomorrow's date" },
{ word = 'yesterday', info = "Yesterday's date" },
{ word = '+1d', info = '1 day from today' },
{ word = '+2d', info = '2 days from today' },
{ word = '+3d', info = '3 days from today' },
{ word = '+1w', info = '1 week from today' },
{ word = '+2w', info = '2 weeks from today' },
{ word = '+1m', info = '1 month from today' },
{ word = 'mon', info = 'Next Monday' },
{ word = 'tue', info = 'Next Tuesday' },
{ word = 'wed', info = 'Next Wednesday' },
{ word = 'thu', info = 'Next Thursday' },
{ word = 'fri', info = 'Next Friday' },
{ word = 'sat', info = 'Next Saturday' },
{ word = 'sun', info = 'Next Sunday' },
{ word = 'eod', info = 'End of day (today)' },
{ word = 'eow', info = 'End of week (Sunday)' },
{ word = 'eom', info = 'End of month' },
{ word = 'eoq', info = 'End of quarter' },
{ word = 'eoy', info = 'End of year (Dec 31)' },
{ word = 'sow', info = 'Start of week (Monday)' },
{ word = 'som', info = 'Start of month' },
{ word = 'soq', info = 'Start of quarter' },
{ word = 'soy', info = 'Start of year (Jan 1)' },
{ word = 'later', info = 'Someday (sentinel date)' },
{ word = 'today@08:00', info = 'Today at 08:00' },
{ word = 'today@09:00', info = 'Today at 09:00' },
{ word = 'today@10:00', info = 'Today at 10:00' },
{ word = 'today@12:00', info = 'Today at 12:00' },
{ word = 'today@14:00', info = 'Today at 14:00' },
{ word = 'today@17:00', info = 'Today at 17:00' },
}
end
---@type table<string, string>
local recur_descriptions = {
daily = 'Every day',
weekdays = 'Monday through Friday',
weekly = 'Every week',
biweekly = 'Every 2 weeks',
monthly = 'Every month',
quarterly = 'Every 3 months',
yearly = 'Every year',
['2d'] = 'Every 2 days',
['3d'] = 'Every 3 days',
['2w'] = 'Every 2 weeks',
['3w'] = 'Every 3 weeks',
['2m'] = 'Every 2 months',
['3m'] = 'Every 3 months',
['6m'] = 'Every 6 months',
['2y'] = 'Every 2 years',
}
---@return { word: string, info: string }[]
local function recur_completions()
local recur = require('pending.recur')
local list = recur.shorthand_list()
local result = {}
for _, s in ipairs(list) do
local desc = recur_descriptions[s] or s
table.insert(result, { word = s, info = desc })
end
for _, s in ipairs(list) do
local desc = recur_descriptions[s] or s
table.insert(result, { word = '!' .. s, info = desc .. ' (from completion date)' })
end
return result
end
---@type string?
local _complete_source = nil
---@param findstart integer
---@param base string
---@return integer|table[]
function M.omnifunc(findstart, base)
if findstart == 1 then
local line = vim.api.nvim_get_current_line()
local col = vim.api.nvim_win_get_cursor(0)[2]
local before = line:sub(1, col)
local dk = date_key()
local rk = recur_key()
local checks = {
{ vim.pesc(dk) .. ':([%S]*)$', dk },
{ 'cat:([%S]*)$', 'cat' },
{ vim.pesc(rk) .. ':([%S]*)$', rk },
}
for _, check in ipairs(checks) do
local start = before:find(check[1])
if start then
local colon_pos = before:find(':', start, true)
if colon_pos then
_complete_source = check[2]
return colon_pos
end
end
end
_complete_source = nil
return -1
end
local matches = {}
local source = _complete_source or ''
local dk = date_key()
local rk = recur_key()
if source == dk then
for _, c in ipairs(date_completions()) do
if base == '' or c.word:sub(1, #base) == base then
table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info })
end
end
elseif source == 'cat' then
for _, c in ipairs(get_categories()) do
if base == '' or c:sub(1, #base) == base then
table.insert(matches, { word = c, menu = '[cat]' })
end
end
elseif source == rk then
for _, c in ipairs(recur_completions()) do
if base == '' or c.word:sub(1, #base) == base then
table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info })
end
end
end
return matches
end
return M

View file

@ -1,16 +1,51 @@
---@class pending.Icons
---@field pending string
---@field done string
---@field priority string
---@field due string
---@field recur string
---@field category string
---@class pending.GcalConfig
---@field calendar? string
---@field credentials_path? string
---@class pending.SyncConfig
---@field gcal? pending.GcalConfig
---@class pending.Keymaps
---@field close? string|false
---@field toggle? string|false
---@field view? string|false
---@field priority? string|false
---@field date? string|false
---@field undo? string|false
---@field filter? string|false
---@field open_line? string|false
---@field open_line_above? string|false
---@field a_task? string|false
---@field i_task? string|false
---@field a_category? string|false
---@field i_category? string|false
---@field next_header? string|false
---@field prev_header? string|false
---@field next_task? string|false
---@field prev_task? string|false
---@class pending.Config
---@field data_path string
---@field default_view 'category'|'priority'
---@field default_category string
---@field date_format string
---@field date_syntax string
---@field recur_syntax string
---@field someday_date string
---@field category_order? string[]
---@field drawer_height? integer
---@field gcal? pending.GcalConfig
---@field debug? boolean
---@field keymaps pending.Keymaps
---@field sync? pending.SyncConfig
---@field icons pending.Icons
---@class pending.config
local M = {}
@ -22,7 +57,37 @@ local defaults = {
default_category = 'Todo',
date_format = '%b %d',
date_syntax = 'due',
recur_syntax = 'rec',
someday_date = '9999-12-30',
category_order = {},
keymaps = {
close = 'q',
toggle = '<CR>',
view = '<Tab>',
priority = '!',
date = 'D',
undo = 'U',
filter = 'F',
open_line = 'o',
open_line_above = 'O',
a_task = 'at',
i_task = 'it',
a_category = 'aC',
i_category = 'iC',
next_header = ']]',
prev_header = '[[',
next_task = ']t',
prev_task = '[t',
},
sync = {},
icons = {
pending = ' ',
done = 'x',
priority = '!',
due = '.',
recur = '~',
category = '#',
},
}
---@type pending.Config?
@ -38,6 +103,7 @@ function M.get()
return _resolved
end
---@return nil
function M.reset()
_resolved = nil
end

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'
@ -10,6 +9,8 @@ local store = require('pending.store')
---@field status? string
---@field category? string
---@field due? string
---@field rec? string
---@field rec_mode? string
---@field lnum integer
---@class pending.diff
@ -25,8 +26,13 @@ end
function M.parse_buffer(lines)
local result = {}
local current_category = nil
local start = 1
if lines[1] and lines[1]:match('^FILTER:') then
start = 2
end
for i, line in ipairs(lines) do
for i = start, #lines do
local line = lines[i]
local id, body = line:match('^/(%d+)/(- %[.%] .*)$')
if not id then
body = line:match('^(- %[.%] .*)$')
@ -48,11 +54,13 @@ function M.parse_buffer(lines)
status = status,
category = metadata.cat or current_category or config.get().default_category,
due = metadata.due,
rec = metadata.rec,
rec_mode = metadata.rec_mode,
lnum = i,
})
end
elseif line:match('^## (.+)$') then
current_category = line:match('^## (.+)$')
elseif line:match('^# (.+)$') then
current_category = line:match('^# (.+)$')
table.insert(result, { type = 'header', category = current_category, lnum = i })
end
end
@ -61,10 +69,13 @@ function M.parse_buffer(lines)
end
---@param lines string[]
function M.apply(lines)
---@param s pending.Store
---@param hidden_ids? table<integer, true>
---@return nil
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
@ -85,11 +96,13 @@ function M.apply(lines)
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,
due = entry.due,
recur = entry.rec,
recur_mode = entry.rec_mode,
order = order_counter,
})
else
@ -112,6 +125,14 @@ function M.apply(lines)
task.due = entry.due
changed = true
end
if task.recur ~= entry.rec then
task.recur = entry.rec
changed = true
end
if task.recur_mode ~= entry.rec_mode then
task.recur_mode = entry.rec_mode
changed = true
end
if entry.status and task.status ~= entry.status then
task.status = entry.status
if entry.status == 'done' then
@ -130,11 +151,13 @@ function M.apply(lines)
end
end
else
store.add({
s:add({
description = entry.description,
category = entry.category,
priority = entry.priority,
due = entry.due,
recur = entry.rec,
recur_mode = entry.rec_mode,
order = order_counter,
})
end
@ -143,14 +166,14 @@ function M.apply(lines)
end
for id, task in pairs(old_by_id) do
if not seen_ids[id] then
if not seen_ids[id] and not (hidden_ids and hidden_ids[id]) then
task.status = 'deleted'
task['end'] = now
task.modified = now
end
end
store.save()
s:save()
end
return M

View file

@ -1,5 +1,6 @@
local M = {}
---@return nil
function M.check()
vim.health.start('pending.nvim')
@ -11,40 +12,64 @@ 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')
else
vim.health.error('Failed to load data file: ' .. tostring(err))
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
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)')
end
if vim.fn.executable('curl') == 1 then
vim.health.ok('curl found (required for Google Calendar sync)')
local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
if #sync_paths == 0 then
vim.health.info('No sync backends found')
else
vim.health.warn('curl not found (needed for Google Calendar sync)')
end
if vim.fn.executable('openssl') == 1 then
vim.health.ok('openssl found (required for OAuth PKCE)')
else
vim.health.warn('openssl not found (needed for Google Calendar OAuth)')
for _, path in ipairs(sync_paths) do
local name = vim.fn.fnamemodify(path, ':t:r')
local bok, backend = pcall(require, 'pending.sync.' .. name)
if bok and type(backend.health) == 'function' then
vim.health.start('pending.nvim: sync/' .. name)
backend.health()
end
end
end
end

View file

@ -3,22 +3,190 @@ local diff = require('pending.diff')
local parse = require('pending.parse')
local store = require('pending.store')
---@class pending.Counts
---@field overdue integer
---@field today integer
---@field pending integer
---@field priority integer
---@field next_due? string
---@class pending.init
local M = {}
---@type pending.Task[][]
local _undo_states = {}
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()
local someday = cfg.someday_date
local overdue = 0
local today = 0
local pending = 0
local priority = 0
local next_due = nil ---@type string?
local today_str = os.date('%Y-%m-%d') --[[@as string]]
for _, task in ipairs(get_store():active_tasks()) do
if task.status == 'pending' then
pending = pending + 1
if task.priority > 0 then
priority = priority + 1
end
if task.due and task.due ~= someday then
if parse.is_overdue(task.due) then
overdue = overdue + 1
elseif parse.is_today(task.due) then
today = today + 1
end
local date_part = task.due:match('^(%d%d%d%d%-%d%d%-%d%d)') or task.due
if date_part >= today_str and (not next_due or task.due < next_due) then
next_due = task.due
end
end
end
end
_counts = {
overdue = overdue,
today = today,
pending = pending,
priority = priority,
next_due = next_due,
}
vim.api.nvim_exec_autocmds('User', { pattern = 'PendingStatusChanged' })
end
---@return nil
local function _save_and_notify()
get_store():save()
M._recompute_counts()
end
---@return pending.Counts
function M.counts()
if not _counts then
get_store():load()
M._recompute_counts()
end
return _counts --[[@as pending.Counts]]
end
---@return string
function M.statusline()
local c = M.counts()
if c.overdue > 0 and c.today > 0 then
return c.overdue .. ' overdue, ' .. c.today .. ' today'
elseif c.overdue > 0 then
return c.overdue .. ' overdue'
elseif c.today > 0 then
return c.today .. ' today'
end
return ''
end
---@return boolean
function M.has_due()
local c = M.counts()
return c.overdue > 0 or c.today > 0
end
---@param tasks pending.Task[]
---@param predicates string[]
---@return table<integer, true>
local function compute_hidden_ids(tasks, predicates)
if #predicates == 0 then
return {}
end
local hidden = {}
for _, task in ipairs(tasks) do
local visible = true
for _, pred in ipairs(predicates) do
local cat_val = pred:match('^cat:(.+)$')
if cat_val then
if task.category ~= cat_val then
visible = false
break
end
elseif pred == 'overdue' then
if not (task.status == 'pending' and task.due and parse.is_overdue(task.due)) then
visible = false
break
end
elseif pred == 'today' then
if not (task.status == 'pending' and task.due and parse.is_today(task.due)) then
visible = false
break
end
elseif pred == 'priority' then
if not (task.priority and task.priority > 0) then
visible = false
break
end
end
end
if not visible then
hidden[task.id] = true
end
end
return hidden
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)
return bufnr
end
---@param pred_str string
---@return nil
function M.filter(pred_str)
if pred_str == 'clear' or pred_str == '' then
buffer.set_filter({}, {})
local bufnr = buffer.bufnr()
if bufnr then
buffer.render(bufnr)
end
return
end
local predicates = {}
for word in pred_str:gmatch('%S+') do
table.insert(predicates, word)
end
local tasks = get_store():active_tasks()
local hidden = compute_hidden_ids(tasks, predicates)
buffer.set_filter(predicates, hidden)
local bufnr = buffer.bufnr()
if bufnr then
buffer.render(bufnr)
end
end
---@param bufnr integer
---@return nil
function M._setup_autocmds(bufnr)
local group = vim.api.nvim_create_augroup('PendingBuffer', { clear = true })
vim.api.nvim_create_autocmd('BufWriteCmd', {
@ -33,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,
@ -49,63 +217,166 @@ function M._setup_autocmds(bufnr)
end
---@param bufnr integer
---@return nil
function M._setup_buf_mappings(bufnr)
local cfg = require('pending.config').get()
local km = cfg.keymaps
local opts = { buffer = bufnr, silent = true }
vim.keymap.set('n', 'q', function()
buffer.close()
end, opts)
vim.keymap.set('n', '<Esc>', function()
buffer.close()
end, opts)
vim.keymap.set('n', '<CR>', function()
M.toggle_complete()
end, opts)
vim.keymap.set('n', '<Tab>', function()
buffer.toggle_view()
end, opts)
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)
vim.keymap.set('n', 'o', function()
buffer.open_line(false)
end, opts)
vim.keymap.set('n', 'O', function()
buffer.open_line(true)
end, opts)
---@type table<string, fun()>
local actions = {
close = function()
buffer.close()
end,
toggle = function()
M.toggle_complete()
end,
view = function()
buffer.toggle_view()
end,
priority = function()
M.toggle_priority()
end,
date = function()
M.prompt_date()
end,
undo = function()
M.undo_write()
end,
filter = function()
vim.ui.input({ prompt = 'Filter: ' }, function(input)
if input then
M.filter(input)
end
end)
end,
open_line = function()
buffer.open_line(false)
end,
open_line_above = function()
buffer.open_line(true)
end,
}
for name, fn in pairs(actions) do
local key = km[name]
if key and key ~= false then
vim.keymap.set('n', key --[[@as string]], fn, opts)
end
end
local textobj = require('pending.textobj')
---@type table<string, { modes: string[], fn: fun(count: integer), visual_fn?: fun(count: integer) }>
local textobjs = {
a_task = {
modes = { 'o', 'x' },
fn = textobj.a_task,
visual_fn = textobj.a_task_visual,
},
i_task = {
modes = { 'o', 'x' },
fn = textobj.i_task,
visual_fn = textobj.i_task_visual,
},
a_category = {
modes = { 'o', 'x' },
fn = textobj.a_category,
visual_fn = textobj.a_category_visual,
},
i_category = {
modes = { 'o', 'x' },
fn = textobj.i_category,
visual_fn = textobj.i_category_visual,
},
}
for name, spec in pairs(textobjs) do
local key = km[name]
if key and key ~= false then
for _, mode in ipairs(spec.modes) do
if mode == 'x' and spec.visual_fn then
vim.keymap.set(mode, key --[[@as string]], function()
spec.visual_fn(vim.v.count1)
end, opts)
else
vim.keymap.set(mode, key --[[@as string]], function()
spec.fn(vim.v.count1)
end, opts)
end
end
end
end
---@type table<string, fun(count: integer)>
local motions = {
next_header = textobj.next_header,
prev_header = textobj.prev_header,
next_task = textobj.next_task,
prev_task = textobj.prev_task,
}
for name, fn in pairs(motions) do
local key = km[name]
if cfg.debug then
vim.notify(
('[pending] mapping motion %s → %s (buf=%d)'):format(name, key or 'nil', bufnr),
vim.log.levels.INFO
)
end
if key and key ~= false then
vim.keymap.set({ 'n', 'x', 'o' }, key --[[@as string]], function()
fn(vim.v.count1)
end, opts)
end
end
end
---@param bufnr integer
---@return nil
function M._on_write(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local snapshot = store.snapshot()
table.insert(_undo_states, snapshot)
if #_undo_states > UNDO_MAX then
table.remove(_undo_states, 1)
local predicates = buffer.filter_predicates()
if lines[1] and lines[1]:match('^FILTER:') then
local pred_str = lines[1]:match('^FILTER:%s*(.*)$') or ''
predicates = {}
for word in pred_str:gmatch('%S+') do
table.insert(predicates, word)
end
lines = vim.list_slice(lines, 2)
elseif #buffer.filter_predicates() > 0 then
predicates = {}
end
diff.apply(lines)
local s = get_store()
local tasks = s:active_tasks()
local hidden = compute_hidden_ids(tasks, predicates)
buffer.set_filter(predicates, hidden)
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, s, hidden)
M._recompute_counts()
buffer.render(bufnr)
end
---@return nil
function M.undo_write()
if #_undo_states == 0 then
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(_undo_states)
store.replace_tasks(state)
store.save()
local state = table.remove(stack)
s:replace_tasks(state)
_save_and_notify()
buffer.render(buffer.bufnr())
end
---@return nil
function M.toggle_complete()
local bufnr = buffer.bufnr()
if not bufnr then
@ -120,16 +391,30 @@ 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
store.update(id, { status = 'done' })
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)
s:add({
description = task.description,
category = task.category,
priority = task.priority,
due = next_date,
recur = task.recur,
recur_mode = task.recur_mode,
})
end
s:update(id, { status = 'done' })
end
store.save()
_save_and_notify()
buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do
if m.id == id then
@ -139,6 +424,7 @@ function M.toggle_complete()
end
end
---@return nil
function M.toggle_priority()
local bufnr = buffer.bufnr()
if not bufnr then
@ -153,13 +439,14 @@ 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 })
store.save()
s:update(id, { priority = new_priority })
_save_and_notify()
buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do
if m.id == id then
@ -169,6 +456,7 @@ function M.toggle_priority()
end
end
---@return nil
function M.prompt_date()
local bufnr = buffer.bufnr()
if not bufnr then
@ -183,7 +471,7 @@ function M.prompt_date()
if not id then
return
end
vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input)
vim.ui.input({ prompt = 'Due date (today, +3d, fri@2pm, etc.): ' }, function(input)
if not input then
return
end
@ -192,35 +480,42 @@ function M.prompt_date()
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)
elseif
not due:match('^%d%d%d%d%-%d%d%-%d%d$')
and not due:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$')
then
vim.notify('Invalid date format. Use YYYY-MM-DD or YYYY-MM-DDThh:mm.', vim.log.levels.ERROR)
return
end
end
store.update(id, { due = due })
store.save()
get_store():update(id, { due = due })
_save_and_notify()
buffer.render(bufnr)
end)
end
---@param text string
---@return nil
function M.add(text)
if not text or text == '' then
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,
recur = metadata.rec,
recur_mode = metadata.rec_mode,
})
store.save()
_save_and_notify()
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
@ -228,25 +523,39 @@ function M.add(text)
vim.notify('Pending added: ' .. description)
end
function M.sync()
local ok, gcal = pcall(require, 'pending.sync.gcal')
if not ok then
vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR)
---@param backend_name string
---@param action? string
---@return nil
function M.sync(backend_name, action)
if not backend_name or backend_name == '' then
vim.notify('Usage: :Pending sync <backend> [action]', vim.log.levels.ERROR)
return
end
gcal.sync()
action = (action and action ~= '') and action or 'sync'
local ok, backend = pcall(require, 'pending.sync.' .. backend_name)
if not ok then
vim.notify('Unknown sync backend: ' .. backend_name, vim.log.levels.ERROR)
return
end
if type(backend[action]) ~= 'function' then
vim.notify(backend_name .. " backend has no '" .. action .. "' action", vim.log.levels.ERROR)
return
end
backend[action]()
end
---@param days? integer
---@return nil
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]],
@ -254,7 +563,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
@ -265,8 +574,8 @@ function M.archive(days)
table.insert(kept, task)
::skip::
end
store.replace_tasks(kept)
store.save()
s:replace_tasks(kept)
_save_and_notify()
vim.notify('Archived ' .. archived .. ' tasks.')
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
@ -274,8 +583,8 @@ function M.archive(days)
end
end
---@return nil
function M.due()
local today = os.date('%Y-%m-%d') --[[@as string]]
local bufnr = buffer.bufnr()
local is_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr)
local meta = is_valid and buffer.meta() or nil
@ -283,9 +592,14 @@ function M.due()
if meta and bufnr then
for lnum, m in ipairs(meta) do
if m.type == 'task' and m.raw_due and m.status ~= 'done' and m.raw_due <= today then
local task = store.get(m.id or 0)
local label = m.raw_due < today and '[OVERDUE] ' or '[DUE] '
if
m.type == 'task'
and m.raw_due
and m.status ~= 'done'
and (parse.is_overdue(m.raw_due) or parse.is_today(m.raw_due))
then
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,
lnum = lnum,
@ -295,10 +609,15 @@ function M.due()
end
end
else
store.load()
for _, task in ipairs(store.active_tasks()) do
if task.status == 'pending' and task.due and task.due <= today then
local label = task.due < today and '[OVERDUE] ' or '[DUE] '
local s = get_store()
s:load()
for _, task in ipairs(s:active_tasks()) do
if
task.status == 'pending'
and task.due
and (parse.is_overdue(task.due) or parse.is_today(task.due))
then
local label = parse.is_overdue(task.due) and '[OVERDUE] ' or '[DUE] '
local text = label .. task.description
if task.category then
text = text .. ' [' .. task.category .. ']'
@ -317,68 +636,194 @@ function M.due()
vim.cmd('copen')
end
function M.show_help()
---@param token string
---@return string|nil field
---@return any value
---@return string|nil err
local function parse_edit_token(token)
local recur = require('pending.recur')
local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due'
local lines = {
'pending.nvim keybindings',
'',
'<CR> Toggle complete/uncomplete',
'<Tab> Switch category/priority view',
'! Toggle urgent',
'D Set due date',
'U Undo last write',
'o / O Add new task line',
'dd Delete task line (on :w)',
'p / P Paste (duplicates get new IDs)',
'zc / zo Fold/unfold category (category view)',
':w Save all changes',
'',
':Pending add <text> Quick-add task',
':Pending add Cat: <text> Quick-add with category',
':Pending due Show overdue/due qflist',
':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',
' cat:Name Set category',
'',
'Due date input:',
' today, tomorrow, +Nd, mon-sun',
' Empty input clears due date',
'',
'Highlights:',
' PendingOverdue overdue tasks (red)',
' PendingPriority [!] urgent tasks',
'',
'Press q or <Esc> to close',
}
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].modifiable = false
vim.bo[buf].bufhidden = 'wipe'
local width = 54
local height = #lines
local win = vim.api.nvim_open_win(buf, true, {
relative = 'editor',
width = width,
height = height,
col = math.floor((vim.o.columns - width) / 2),
row = math.floor((vim.o.lines - height) / 2),
style = 'minimal',
border = 'rounded',
})
vim.keymap.set('n', 'q', function()
vim.api.nvim_win_close(win, true)
end, { buffer = buf, silent = true })
vim.keymap.set('n', '<Esc>', function()
vim.api.nvim_win_close(win, true)
end, { buffer = buf, silent = true })
local rk = cfg.recur_syntax or 'rec'
if token == '+!' then
return 'priority', 1, nil
end
if token == '-!' then
return 'priority', 0, nil
end
if token == '-due' or token == '-' .. dk then
return 'due', vim.NIL, nil
end
if token == '-cat' then
return 'category', vim.NIL, nil
end
if token == '-rec' or token == '-' .. rk then
return 'recur', vim.NIL, nil
end
local due_val = token:match('^' .. vim.pesc(dk) .. ':(.+)$')
if due_val then
local resolved = parse.resolve_date(due_val)
if resolved then
return 'due', resolved, nil
end
if
due_val:match('^%d%d%d%d%-%d%d%-%d%d$') or due_val:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$')
then
return 'due', due_val, nil
end
return nil,
nil,
'Invalid date: ' .. due_val .. '. Use YYYY-MM-DD, today, tomorrow, +Nd, weekday names, etc.'
end
local cat_val = token:match('^cat:(.+)$')
if cat_val then
return 'category', cat_val, nil
end
local rec_val = token:match('^' .. vim.pesc(rk) .. ':(.+)$')
if rec_val then
local raw_spec = rec_val
local rec_mode = nil
if raw_spec:sub(1, 1) == '!' then
rec_mode = 'completion'
raw_spec = raw_spec:sub(2)
end
if not recur.validate(raw_spec) then
return nil, nil, 'Invalid recurrence pattern: ' .. rec_val
end
return 'recur', { spec = raw_spec, mode = rec_mode }, nil
end
return nil,
nil,
'Unknown operation: '
.. token
.. '. Valid: '
.. dk
.. ':<date>, cat:<name>, '
.. rk
.. ':<pattern>, +!, -!, -'
.. dk
.. ', -cat, -'
.. rk
end
---@param id_str string
---@param rest string
---@return nil
function M.edit(id_str, rest)
if not id_str or id_str == '' then
vim.notify(
'Usage: :Pending edit <id> [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-due] [-cat] [-rec]',
vim.log.levels.ERROR
)
return
end
local id = tonumber(id_str)
if not id then
vim.notify('Invalid task ID: ' .. id_str, vim.log.levels.ERROR)
return
end
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
end
if not rest or rest == '' then
vim.notify(
'Usage: :Pending edit <id> [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-due] [-cat] [-rec]',
vim.log.levels.ERROR
)
return
end
local tokens = {}
for tok in rest:gmatch('%S+') do
table.insert(tokens, tok)
end
local updates = {}
local feedback = {}
for _, tok in ipairs(tokens) do
local field, value, err = parse_edit_token(tok)
if err then
vim.notify(err, vim.log.levels.ERROR)
return
end
if field == 'recur' then
if value == vim.NIL then
updates.recur = vim.NIL
updates.recur_mode = vim.NIL
table.insert(feedback, 'recurrence removed')
else
updates.recur = value.spec
updates.recur_mode = value.mode
table.insert(feedback, 'recurrence set to ' .. value.spec)
end
elseif field == 'due' then
if value == vim.NIL then
updates.due = vim.NIL
table.insert(feedback, 'due date removed')
else
updates.due = value
table.insert(feedback, 'due date set to ' .. tostring(value))
end
elseif field == 'category' then
if value == vim.NIL then
updates.category = vim.NIL
table.insert(feedback, 'category removed')
else
updates.category = value
table.insert(feedback, 'category set to ' .. tostring(value))
end
elseif field == 'priority' then
updates.priority = value
table.insert(feedback, value == 1 and 'priority added' or 'priority removed')
end
end
local snapshot = s:snapshot()
local stack = s:undo_stack()
table.insert(stack, snapshot)
if #stack > UNDO_MAX then
table.remove(stack, 1)
end
s:update(id, updates)
s:save()
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
end
vim.notify('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', '))
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)
if not args or args == '' then
M.open()
@ -387,15 +832,23 @@ function M.command(args)
local cmd, rest = args:match('^(%S+)%s*(.*)')
if cmd == 'add' then
M.add(rest)
elseif cmd == 'edit' then
local id_str, edit_rest = rest:match('^(%S+)%s*(.*)')
M.edit(id_str, edit_rest)
elseif cmd == 'sync' then
M.sync()
local backend, action = rest:match('^(%S+)%s*(.*)')
M.sync(backend, action)
elseif cmd == 'archive' then
local d = rest ~= '' and tonumber(rest) or nil
M.archive(d)
elseif cmd == 'due' then
M.due()
elseif cmd == 'filter' then
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

@ -24,11 +24,92 @@ local function is_valid_date(s)
return check.year == yn and check.month == mn and check.day == dn
end
---@param s string
---@return boolean
local function is_valid_time(s)
local h, m = s:match('^(%d%d):(%d%d)$')
if not h then
return false
end
local hn = tonumber(h) --[[@as integer]]
local mn = tonumber(m) --[[@as integer]]
return hn >= 0 and hn <= 23 and mn >= 0 and mn <= 59
end
---@param s string
---@return string|nil
local function normalize_time(s)
local h, m, period
h, m, period = s:match('^(%d+):(%d%d)([ap]m)$')
if not h then
h, period = s:match('^(%d+)([ap]m)$')
if h then
m = '00'
end
end
if not h then
h, m = s:match('^(%d%d):(%d%d)$')
end
if not h then
h, m = s:match('^(%d):(%d%d)$')
end
if not h then
h = s:match('^(%d+)$')
if h then
m = '00'
end
end
if not h then
return nil
end
local hn = tonumber(h) --[[@as integer]]
local mn = tonumber(m) --[[@as integer]]
if period then
if hn < 1 or hn > 12 then
return nil
end
if period == 'am' then
hn = hn == 12 and 0 or hn
else
hn = hn == 12 and 12 or hn + 12
end
else
if hn < 0 or hn > 23 then
return nil
end
end
if mn < 0 or mn > 59 then
return nil
end
return string.format('%02d:%02d', hn, mn)
end
---@param s string
---@return boolean
local function is_valid_datetime(s)
local date_part, time_part = s:match('^(.+)T(.+)$')
if not date_part then
return is_valid_date(s)
end
return is_valid_date(date_part) and is_valid_time(time_part)
end
---@return string
local function date_key()
return config.get().date_syntax or 'due'
end
---@return string
local function recur_key()
return config.get().recur_syntax or 'rec'
end
local weekday_map = {
sun = 1,
mon = 2,
@ -39,45 +120,295 @@ local weekday_map = {
sat = 7,
}
local month_map = {
jan = 1,
feb = 2,
mar = 3,
apr = 4,
may = 5,
jun = 6,
jul = 7,
aug = 8,
sep = 9,
oct = 10,
nov = 11,
dec = 12,
}
---@param today osdate
---@return string
local function today_str(today)
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]]
end
---@param date_part string
---@param time_suffix? string
---@return string
local function append_time(date_part, time_suffix)
if time_suffix then
return date_part .. 'T' .. time_suffix
end
return date_part
end
---@param text string
---@return string|nil
function M.resolve_date(text)
local lower = text:lower()
local date_input, time_suffix = text:match('^(.+)@(.+)$')
if time_suffix then
time_suffix = normalize_time(time_suffix)
if not time_suffix then
return nil
end
else
date_input = text
end
local dt = date_input:match('^(%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d)$')
if dt then
local dp, tp = dt:match('^(.+)T(.+)$')
if is_valid_date(dp) and is_valid_time(tp) then
return dt
end
return nil
end
if is_valid_date(date_input) then
return append_time(date_input, time_suffix)
end
local lower = date_input:lower()
local today = os.date('*t') --[[@as osdate]]
if lower == 'today' then
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]]
if lower == 'today' or lower == 'eod' then
return append_time(today_str(today), time_suffix)
end
if lower == 'yesterday' then
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day - 1 })) --[[@as string]],
time_suffix
)
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]]
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) --[[@as string]],
time_suffix
)
end
if lower == 'sow' then
local delta = -((today.wday - 2) % 7)
return append_time(
os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + delta })
) --[[@as string]],
time_suffix
)
end
if lower == 'eow' then
local delta = (1 - today.wday) % 7
return append_time(
os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + delta })
) --[[@as string]],
time_suffix
)
end
if lower == 'som' then
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = 1 })) --[[@as string]],
time_suffix
)
end
if lower == 'eom' then
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 })) --[[@as string]],
time_suffix
)
end
if lower == 'soq' then
local q = math.ceil(today.month / 3)
local first_month = (q - 1) * 3 + 1
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = first_month, day = 1 })) --[[@as string]],
time_suffix
)
end
if lower == 'eoq' then
local q = math.ceil(today.month / 3)
local last_month = q * 3
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 })) --[[@as string]],
time_suffix
)
end
if lower == 'soy' then
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = 1, day = 1 })) --[[@as string]],
time_suffix
)
end
if lower == 'eoy' then
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = 12, day = 31 })) --[[@as string]],
time_suffix
)
end
if lower == 'later' or lower == 'someday' then
return append_time(config.get().someday_date, time_suffix)
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]]
return append_time(
os.date(
'%Y-%m-%d',
os.time({
year = today.year,
month = today.month,
day = today.day + (
tonumber(n) --[[@as integer]]
),
})
) --[[@as string]],
time_suffix
)
end
n = lower:match('^%+(%d+)w$')
if n then
return append_time(
os.date(
'%Y-%m-%d',
os.time({
year = today.year,
month = today.month,
day = today.day + (
tonumber(n) --[[@as integer]]
) * 7,
})
) --[[@as string]],
time_suffix
)
end
n = lower:match('^%+(%d+)m$')
if n then
return append_time(
os.date(
'%Y-%m-%d',
os.time({
year = today.year,
month = today.month + (
tonumber(n) --[[@as integer]]
),
day = today.day,
})
) --[[@as string]],
time_suffix
)
end
n = lower:match('^%-(%d+)d$')
if n then
return append_time(
os.date(
'%Y-%m-%d',
os.time({
year = today.year,
month = today.month,
day = today.day - (
tonumber(n) --[[@as integer]]
),
})
) --[[@as string]],
time_suffix
)
end
n = lower:match('^%-(%d+)w$')
if n then
return append_time(
os.date(
'%Y-%m-%d',
os.time({
year = today.year,
month = today.month,
day = today.day - (
tonumber(n) --[[@as integer]]
) * 7,
})
) --[[@as string]],
time_suffix
)
end
local ord = lower:match('^(%d+)[snrt][tdh]$')
if ord then
local day_num = tonumber(ord) --[[@as integer]]
if day_num >= 1 and day_num <= 31 then
local m, y = today.month, today.year
if today.day >= day_num then
m = m + 1
if m > 12 then
m = 1
y = y + 1
end
end
local t = os.time({ year = y, month = m, day = day_num })
local check = os.date('*t', t) --[[@as osdate]]
if check.day == day_num then
return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
end
m = m + 1
if m > 12 then
m = 1
y = y + 1
end
t = os.time({ year = y, month = m, day = day_num })
check = os.date('*t', t) --[[@as osdate]]
if check.day == day_num then
return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
end
return nil
end
end
local target_month = month_map[lower]
if target_month then
local y = today.year
if today.month >= target_month then
y = y + 1
end
return append_time(
os.date('%Y-%m-%d', os.time({ year = y, month = target_month, day = 1 })) --[[@as string]],
time_suffix
)
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]]
return append_time(
os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + delta })
) --[[@as string]],
time_suffix
)
end
return nil
@ -85,7 +416,7 @@ end
---@param text string
---@return string description
---@return { due?: string, cat?: string } metadata
---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } metadata
function M.body(text)
local tokens = {}
for token in text:gmatch('%S+') do
@ -95,8 +426,10 @@ function M.body(text)
local metadata = {}
local i = #tokens
local dk = date_key()
local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$'
local rk = recur_key()
local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d[T%d:]*)$'
local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$'
local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$'
while i >= 1 do
local token = tokens[i]
@ -105,7 +438,7 @@ function M.body(text)
if metadata.due then
break
end
if not is_valid_date(due_val) then
if not is_valid_datetime(due_val) then
break
end
metadata.due = due_val
@ -131,7 +464,25 @@ function M.body(text)
metadata.cat = cat_val
i = i - 1
else
break
local rec_val = token:match(rec_pattern)
if rec_val then
if metadata.rec then
break
end
local recur = require('pending.recur')
local raw_spec = rec_val
if raw_spec:sub(1, 1) == '!' then
metadata.rec_mode = 'completion'
raw_spec = raw_spec:sub(2)
end
if not recur.validate(raw_spec) then
break
end
metadata.rec = raw_spec
i = i - 1
else
break
end
end
end
end
@ -148,7 +499,7 @@ end
---@param text string
---@return string description
---@return { due?: string, cat?: string } metadata
---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } metadata
function M.command_add(text)
local cat_prefix = text:match('^(%S.-):%s')
if cat_prefix then
@ -165,4 +516,39 @@ function M.command_add(text)
return M.body(text)
end
---@param due string
---@return boolean
function M.is_overdue(due)
local now = os.date('*t') --[[@as osdate]]
local today = os.date('%Y-%m-%d') --[[@as string]]
local date_part, time_part = due:match('^(.+)T(.+)$')
if not date_part then
return due < today
end
if date_part < today then
return true
end
if date_part > today then
return false
end
local current_time = string.format('%02d:%02d', now.hour, now.min)
return time_part < current_time
end
---@param due string
---@return boolean
function M.is_today(due)
local now = os.date('*t') --[[@as osdate]]
local today = os.date('%Y-%m-%d') --[[@as string]]
local date_part, time_part = due:match('^(.+)T(.+)$')
if not date_part then
return due == today
end
if date_part ~= today then
return false
end
local current_time = string.format('%02d:%02d', now.hour, now.min)
return time_part >= current_time
end
return M

188
lua/pending/recur.lua Normal file
View file

@ -0,0 +1,188 @@
---@class pending.RecurSpec
---@field freq 'daily'|'weekly'|'monthly'|'yearly'
---@field interval integer
---@field byday? string[]
---@field from_completion boolean
---@field _raw? string
---@class pending.recur
local M = {}
---@type table<string, pending.RecurSpec>
local named = {
daily = { freq = 'daily', interval = 1, from_completion = false },
weekdays = {
freq = 'weekly',
interval = 1,
byday = { 'MO', 'TU', 'WE', 'TH', 'FR' },
from_completion = false,
},
weekly = { freq = 'weekly', interval = 1, from_completion = false },
biweekly = { freq = 'weekly', interval = 2, from_completion = false },
monthly = { freq = 'monthly', interval = 1, from_completion = false },
quarterly = { freq = 'monthly', interval = 3, from_completion = false },
yearly = { freq = 'yearly', interval = 1, from_completion = false },
annual = { freq = 'yearly', interval = 1, from_completion = false },
}
---@param spec string
---@return pending.RecurSpec?
function M.parse(spec)
local from_completion = false
local s = spec
if s:sub(1, 1) == '!' then
from_completion = true
s = s:sub(2)
end
local lower = s:lower()
local base = named[lower]
if base then
return {
freq = base.freq,
interval = base.interval,
byday = base.byday,
from_completion = from_completion,
}
end
local n, unit = lower:match('^(%d+)([dwmy])$')
if n then
local num = tonumber(n) --[[@as integer]]
if num < 1 then
return nil
end
local freq_map = { d = 'daily', w = 'weekly', m = 'monthly', y = 'yearly' }
return {
freq = freq_map[unit],
interval = num,
from_completion = from_completion,
}
end
if s:match('^FREQ=') then
return {
freq = 'daily',
interval = 1,
from_completion = from_completion,
_raw = s,
}
end
return nil
end
---@param spec string
---@return boolean
function M.validate(spec)
return M.parse(spec) ~= nil
end
---@param due string
---@return string date_part
---@return string? time_part
local function split_datetime(due)
local dp, tp = due:match('^(.+)T(.+)$')
if dp then
return dp, tp
end
return due, nil
end
---@param base_date string
---@param freq string
---@param interval integer
---@return string
local function advance_date(base_date, freq, interval)
local date_part, time_part = split_datetime(base_date)
local y, m, d = date_part:match('^(%d+)-(%d+)-(%d+)$')
local yn = tonumber(y) --[[@as integer]]
local mn = tonumber(m) --[[@as integer]]
local dn = tonumber(d) --[[@as integer]]
local result
if freq == 'daily' then
result = os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval })) --[[@as string]]
elseif freq == 'weekly' then
result = os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval * 7 })) --[[@as string]]
elseif freq == 'monthly' then
local new_m = mn + interval
local new_y = yn
while new_m > 12 do
new_m = new_m - 12
new_y = new_y + 1
end
local last_day = os.date('*t', os.time({ year = new_y, month = new_m + 1, day = 0 })) --[[@as osdate]]
local clamped_d = math.min(dn, last_day.day --[[@as integer]])
result = os.date('%Y-%m-%d', os.time({ year = new_y, month = new_m, day = clamped_d })) --[[@as string]]
elseif freq == 'yearly' then
local new_y = yn + interval
local last_day = os.date('*t', os.time({ year = new_y, month = mn + 1, day = 0 })) --[[@as osdate]]
local clamped_d = math.min(dn, last_day.day --[[@as integer]])
result = os.date('%Y-%m-%d', os.time({ year = new_y, month = mn, day = clamped_d })) --[[@as string]]
else
return base_date
end
if time_part then
return result .. 'T' .. time_part
end
return result
end
---@param base_date string
---@param spec string
---@param mode 'scheduled'|'completion'
---@return string
function M.next_due(base_date, spec, mode)
local parsed = M.parse(spec)
if not parsed then
return base_date
end
local today = os.date('%Y-%m-%d') --[[@as string]]
local _, time_part = split_datetime(base_date)
if mode == 'completion' then
local base = time_part and (today .. 'T' .. time_part) or today
return advance_date(base, parsed.freq, parsed.interval)
end
local next_date = advance_date(base_date, parsed.freq, parsed.interval)
local compare_today = time_part and (today .. 'T' .. time_part) or today
while next_date <= compare_today do
next_date = advance_date(next_date, parsed.freq, parsed.interval)
end
return next_date
end
---@param spec string
---@return string
function M.to_rrule(spec)
local parsed = M.parse(spec)
if not parsed then
return ''
end
if parsed._raw then
return 'RRULE:' .. parsed._raw
end
local parts = { 'FREQ=' .. parsed.freq:upper() }
if parsed.interval > 1 then
table.insert(parts, 'INTERVAL=' .. parsed.interval)
end
if parsed.byday then
table.insert(parts, 'BYDAY=' .. table.concat(parsed.byday, ','))
end
return 'RRULE:' .. table.concat(parts, ';')
end
---@return string[]
function M.shorthand_list()
return { 'daily', 'weekdays', 'weekly', 'biweekly', 'monthly', 'quarterly', 'yearly', 'annual' }
end
return M

View file

@ -7,6 +7,8 @@ local config = require('pending.config')
---@field category? string
---@field priority integer
---@field due? string
---@field recur? string
---@field recur_mode? 'scheduled'|'completion'
---@field entry string
---@field modified string
---@field end? string
@ -17,21 +19,26 @@ local config = require('pending.config')
---@field version integer
---@field next_id integer
---@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 {
version = SUPPORTED_VERSION,
next_id = 1,
tasks = {},
undo = {},
}
end
@ -56,6 +63,8 @@ local known_fields = {
category = true,
priority = true,
due = true,
recur = true,
recur_mode = true,
entry = true,
modified = true,
['end'] = true,
@ -81,6 +90,12 @@ local function task_to_table(task)
if task.due then
t.due = task.due
end
if task.recur then
t.recur = task.recur
end
if task.recur_mode then
t.recur_mode = task.recur_mode
end
if task['end'] then
t['end'] = task['end']
end
@ -105,6 +120,8 @@ local function table_to_task(t)
category = t.category,
priority = t.priority or 0,
due = t.due,
recur = t.recur,
recur_mode = t.recur_mode,
entry = t.entry,
modified = t.modified,
['end'] = t['end'],
@ -123,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
@ -149,31 +166,50 @@ 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
return _data
for _, snapshot in ipairs(decoded.undo or {}) do
if type(snapshot) == 'table' then
local tasks = {}
for _, raw in ipairs(snapshot) do
table.insert(tasks, table_to_task(raw))
end
table.insert(self._data.undo, tasks)
end
end
return self._data
end
function M.save()
if not _data then
---@return nil
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(self._data.undo) do
local serialized = {}
for _, task in ipairs(snapshot) do
table.insert(serialized, task_to_table(task))
end
table.insert(out.undo, serialized)
end
local encoded = vim.json.encode(out)
local tmp = path .. '.tmp'
local f = io.open(tmp, 'w')
@ -190,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
@ -215,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
@ -224,10 +260,10 @@ function M.get(id)
return nil
end
---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, order?: integer, _extra?: table }
---@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,
@ -236,6 +272,8 @@ function M.add(fields)
category = fields.category or config.get().default_category,
priority = fields.priority or 0,
due = fields.due,
recur = fields.recur,
recur_mode = fields.recur_mode,
entry = now,
modified = now,
['end'] = nil,
@ -250,15 +288,19 @@ 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
local now = timestamp()
for k, v in pairs(fields) do
if k ~= 'id' and k ~= 'entry' then
task[k] = v
if v == vim.NIL then
task[k] = nil
else
task[k] = v
end
end
end
task.modified = now
@ -270,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
@ -286,14 +328,15 @@ function M.find_index(id)
end
---@param tasks pending.Task[]
function M.replace_tasks(tasks)
M.data().tasks = tasks
---@return nil
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
@ -311,13 +354,45 @@ function M.snapshot()
return result
end
---@param id integer
function M.set_next_id(id)
M.data().next_id = id
---@return pending.Task[][]
function Store:undo_stack()
return self:data().undo
end
function M.unload()
_data = nil
---@param stack pending.Task[][]
---@return nil
function Store:set_undo_stack(stack)
self:data().undo = stack
end
---@param id integer
---@return nil
function Store:set_next_id(id)
self:data().next_id = id
end
---@return 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,8 +1,9 @@
local config = require('pending.config')
local store = require('pending.store')
local M = {}
M.name = 'gcal'
local BASE_URL = 'https://www.googleapis.com/calendar/v3'
local TOKEN_URL = 'https://oauth2.googleapis.com/token'
local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
@ -22,7 +23,7 @@ local SCOPE = 'https://www.googleapis.com/auth/calendar'
---@return table<string, any>
local function gcal_config()
local cfg = config.get()
return cfg.gcal or {}
return (cfg.sync and cfg.sync.gcal) or {}
end
---@return string
@ -199,7 +200,7 @@ local function get_access_token()
end
local tokens = load_tokens()
if not tokens or not tokens.refresh_token then
M.authorize()
M.auth()
tokens = load_tokens()
if not tokens then
return nil
@ -218,7 +219,7 @@ local function get_access_token()
return tokens.access_token
end
function M.authorize()
function M.auth()
local creds = load_credentials()
if not creds then
vim.notify(
@ -456,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
@ -502,7 +503,8 @@ function M.sync()
end
end
store.save()
require('pending').store():save()
require('pending')._recompute_counts()
vim.notify(
string.format(
'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)',
@ -513,4 +515,18 @@ function M.sync()
)
end
---@return nil
function M.health()
if vim.fn.executable('curl') == 1 then
vim.health.ok('curl found (required for gcal sync)')
else
vim.health.warn('curl not found (needed for gcal sync)')
end
if vim.fn.executable('openssl') == 1 then
vim.health.ok('openssl found (required for gcal OAuth PKCE)')
else
vim.health.warn('openssl not found (needed for gcal OAuth)')
end
end
return M

384
lua/pending/textobj.lua Normal file
View file

@ -0,0 +1,384 @@
local buffer = require('pending.buffer')
local config = require('pending.config')
---@class pending.textobj
local M = {}
---@param ... any
---@return nil
local function dbg(...)
if config.get().debug then
vim.notify('[pending.textobj] ' .. string.format(...), vim.log.levels.INFO)
end
end
---@param lnum integer
---@param meta pending.LineMeta[]
---@return string
local function get_line_from_buf(lnum, meta)
local _ = meta
local bufnr = buffer.bufnr()
if not bufnr then
return ''
end
local lines = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, false)
return lines[1] or ''
end
---@param line string
---@return integer start_col
---@return integer end_col
function M.inner_task_range(line)
local prefix_end = line:find('/') and select(2, line:find('^/%d+/%- %[.%] '))
if not prefix_end then
prefix_end = select(2, line:find('^%- %[.%] ')) or 0
end
local start_col = prefix_end + 1
local dk = config.get().date_syntax or 'due'
local rk = config.get().recur_syntax or 'rec'
local dk_pat = '^' .. vim.pesc(dk) .. ':%S+$'
local rk_pat = '^' .. vim.pesc(rk) .. ':%S+$'
local rest = line:sub(start_col)
local words = {}
for word in rest:gmatch('%S+') do
table.insert(words, word)
end
local i = #words
while i >= 1 do
local word = words[i]
if word:match(dk_pat) or word:match('^cat:%S+$') or word:match(rk_pat) then
i = i - 1
else
break
end
end
if i < 1 then
return start_col, start_col
end
local desc = table.concat(words, ' ', 1, i)
local end_col = start_col + #desc - 1
return start_col, end_col
end
---@param row integer
---@param meta pending.LineMeta[]
---@return integer? header_row
---@return integer? last_row
function M.category_bounds(row, meta)
if not meta or #meta == 0 then
return nil, nil
end
local header_row = nil
local m = meta[row]
if not m then
return nil, nil
end
if m.type == 'header' then
header_row = row
else
for r = row, 1, -1 do
if meta[r] and meta[r].type == 'header' then
header_row = r
break
end
end
end
if not header_row then
return nil, nil
end
local last_row = header_row
local total = #meta
for r = header_row + 1, total do
if meta[r].type == 'header' then
break
end
last_row = r
end
return header_row, last_row
end
---@param count integer
---@return nil
function M.a_task(count)
local meta = buffer.meta()
if not meta or #meta == 0 then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local m = meta[row]
if not m or m.type ~= 'task' then
return
end
local start_row = row
local end_row = row
count = math.max(1, count)
for _ = 2, count do
local next_row = end_row + 1
if next_row > #meta then
break
end
if meta[next_row] and meta[next_row].type == 'task' then
end_row = next_row
else
break
end
end
vim.cmd('normal! ' .. start_row .. 'GV' .. end_row .. 'G')
end
---@param count integer
---@return nil
function M.a_task_visual(count)
vim.cmd('normal! \27')
M.a_task(count)
end
---@param count integer
---@return nil
function M.i_task(count)
local _ = count
local meta = buffer.meta()
if not meta or #meta == 0 then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local m = meta[row]
if not m or m.type ~= 'task' then
return
end
local line = get_line_from_buf(row, meta)
local start_col, end_col = M.inner_task_range(line)
if start_col > end_col then
return
end
vim.api.nvim_win_set_cursor(0, { row, start_col - 1 })
vim.cmd('normal! v')
vim.api.nvim_win_set_cursor(0, { row, end_col - 1 })
end
---@param count integer
---@return nil
function M.i_task_visual(count)
vim.cmd('normal! \27')
M.i_task(count)
end
---@param count integer
---@return nil
function M.a_category(count)
local _ = count
local meta = buffer.meta()
if not meta or #meta == 0 then
return
end
local view = buffer.current_view_name()
if view == 'priority' then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local header_row, last_row = M.category_bounds(row, meta)
if not header_row or not last_row then
return
end
local start_row = header_row
if header_row > 1 and meta[header_row - 1] and meta[header_row - 1].type == 'blank' then
start_row = header_row - 1
end
local end_row = last_row
if last_row < #meta and meta[last_row + 1] and meta[last_row + 1].type == 'blank' then
end_row = last_row + 1
end
vim.cmd('normal! ' .. start_row .. 'GV' .. end_row .. 'G')
end
---@param count integer
---@return nil
function M.a_category_visual(count)
vim.cmd('normal! \27')
M.a_category(count)
end
---@param count integer
---@return nil
function M.i_category(count)
local _ = count
local meta = buffer.meta()
if not meta or #meta == 0 then
return
end
local view = buffer.current_view_name()
if view == 'priority' then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local header_row, last_row = M.category_bounds(row, meta)
if not header_row or not last_row then
return
end
local first_task = nil
local last_task = nil
for r = header_row + 1, last_row do
if meta[r] and meta[r].type == 'task' then
if not first_task then
first_task = r
end
last_task = r
end
end
if not first_task or not last_task then
return
end
vim.cmd('normal! ' .. first_task .. 'GV' .. last_task .. 'G')
end
---@param count integer
---@return nil
function M.i_category_visual(count)
vim.cmd('normal! \27')
M.i_category(count)
end
---@param count integer
---@return nil
function M.next_header(count)
local meta = buffer.meta()
if not meta or #meta == 0 then
return
end
local view = buffer.current_view_name()
if view == 'priority' then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
dbg('next_header: cursor=%d, meta_len=%d, view=%s', row, #meta, view or 'nil')
local found = 0
count = math.max(1, count)
for r = row + 1, #meta do
if meta[r] and meta[r].type == 'header' then
found = found + 1
dbg(
'next_header: found header at row=%d, cat=%s, found=%d/%d',
r,
meta[r].category or '?',
found,
count
)
if found == count then
vim.api.nvim_win_set_cursor(0, { r, 0 })
dbg('next_header: cursor set to row=%d, actual=%d', r, vim.api.nvim_win_get_cursor(0)[1])
return
end
else
dbg('next_header: row=%d type=%s', r, meta[r] and meta[r].type or 'nil')
end
end
dbg('next_header: no header found after row=%d', row)
end
---@param count integer
---@return nil
function M.prev_header(count)
local meta = buffer.meta()
if not meta or #meta == 0 then
return
end
local view = buffer.current_view_name()
if view == 'priority' then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
dbg('prev_header: cursor=%d, meta_len=%d', row, #meta)
local found = 0
count = math.max(1, count)
for r = row - 1, 1, -1 do
if meta[r] and meta[r].type == 'header' then
found = found + 1
dbg(
'prev_header: found header at row=%d, cat=%s, found=%d/%d',
r,
meta[r].category or '?',
found,
count
)
if found == count then
vim.api.nvim_win_set_cursor(0, { r, 0 })
return
end
end
end
end
---@param count integer
---@return nil
function M.next_task(count)
local meta = buffer.meta()
if not meta or #meta == 0 then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
dbg('next_task: cursor=%d, meta_len=%d', row, #meta)
local found = 0
count = math.max(1, count)
for r = row + 1, #meta do
if meta[r] and meta[r].type == 'task' then
found = found + 1
if found == count then
dbg('next_task: jumping to row=%d', r)
vim.api.nvim_win_set_cursor(0, { r, 0 })
return
end
end
end
dbg('next_task: no task found after row=%d', row)
end
---@param count integer
---@return nil
function M.prev_task(count)
local meta = buffer.meta()
if not meta or #meta == 0 then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
dbg('prev_task: cursor=%d, meta_len=%d', row, #meta)
local found = 0
count = math.max(1, count)
for r = row - 1, 1, -1 do
if meta[r] and meta[r].type == 'task' then
found = found + 1
if found == count then
dbg('prev_task: jumping to row=%d', r)
vim.api.nvim_win_set_cursor(0, { r, 0 })
return
end
end
end
dbg('prev_task: no task found before row=%d', row)
end
return M

View file

@ -1,7 +1,8 @@
local config = require('pending.config')
local parse = require('pending.parse')
---@class pending.LineMeta
---@field type 'task'|'header'|'blank'
---@field type 'task'|'header'|'blank'|'filter'
---@field id? integer
---@field due? string
---@field raw_due? string
@ -10,6 +11,7 @@ local config = require('pending.config')
---@field overdue? boolean
---@field show_category? boolean
---@field priority? integer
---@field recur? string
---@class pending.views
local M = {}
@ -20,7 +22,10 @@ local function format_due(due)
if not due then
return nil
end
local y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
local y, m, d, hh, mm = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d)$')
if not y then
y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
end
if not y then
return due
end
@ -29,7 +34,11 @@ local function format_due(due)
month = tonumber(m) --[[@as integer]],
day = tonumber(d) --[[@as integer]],
})
return os.date(config.get().date_format, t) --[[@as string]]
local formatted = os.date(config.get().date_format, t) --[[@as string]]
if hh then
formatted = formatted .. ' ' .. hh .. ':' .. mm
end
return formatted
end
---@param tasks pending.Task[]
@ -73,7 +82,6 @@ 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 = {}
@ -125,7 +133,7 @@ function M.category_view(tasks)
table.insert(lines, '')
table.insert(meta, { type = 'blank' })
end
table.insert(lines, '## ' .. cat)
table.insert(lines, '# ' .. cat)
table.insert(meta, { type = 'header', category = cat })
local all = {}
@ -148,7 +156,9 @@ 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,
overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due)
or nil,
recur = task.recur,
})
end
end
@ -160,7 +170,6 @@ 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 = {}
@ -198,8 +207,9 @@ 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,
overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) or nil,
show_category = true,
recur = task.recur,
})
end

View file

@ -3,16 +3,228 @@ if vim.g.loaded_pending then
end
vim.g.loaded_pending = true
---@return string[]
local function edit_field_candidates()
local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
return {
dk .. ':',
'cat:',
rk .. ':',
'+!',
'-!',
'-' .. dk,
'-cat',
'-' .. rk,
}
end
---@return string[]
local function edit_date_values()
return {
'today',
'tomorrow',
'yesterday',
'+1d',
'+2d',
'+3d',
'+1w',
'+2w',
'+1m',
'mon',
'tue',
'wed',
'thu',
'fri',
'sat',
'sun',
'eod',
'eow',
'eom',
'eoq',
'eoy',
'sow',
'som',
'soq',
'soy',
'later',
}
end
---@return string[]
local function edit_recur_values()
local ok, recur = pcall(require, 'pending.recur')
if not ok then
return {}
end
local result = {}
for _, s in ipairs(recur.shorthand_list()) do
table.insert(result, s)
end
for _, s in ipairs(recur.shorthand_list()) do
table.insert(result, '!' .. s)
end
return result
end
---@param lead string
---@param candidates string[]
---@return string[]
local function filter_candidates(lead, candidates)
return vim.tbl_filter(function(s)
return s:find(lead, 1, true) == 1
end, candidates)
end
---@param arg_lead string
---@param cmd_line string
---@return string[]
local function complete_edit(arg_lead, cmd_line)
local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
local after_edit = cmd_line:match('^Pending%s+edit%s+(.*)')
if not after_edit then
return {}
end
local parts = {}
for part in after_edit:gmatch('%S+') do
table.insert(parts, part)
end
local trailing_space = after_edit:match('%s$')
if #parts == 0 or (#parts == 1 and not trailing_space) then
local store = require('pending.store')
local s = store.new(store.resolve_path())
s:load()
local ids = {}
for _, task in ipairs(s:active_tasks()) do
table.insert(ids, tostring(task.id))
end
return filter_candidates(arg_lead, ids)
end
local prefix = arg_lead:match('^(' .. vim.pesc(dk) .. ':)(.*)$')
if prefix then
local after_colon = arg_lead:sub(#prefix + 1)
local dates = edit_date_values()
local result = {}
for _, d in ipairs(dates) do
if d:find(after_colon, 1, true) == 1 then
table.insert(result, prefix .. d)
end
end
return result
end
local rec_prefix = arg_lead:match('^(' .. vim.pesc(rk) .. ':)(.*)$')
if rec_prefix then
local after_colon = arg_lead:sub(#rec_prefix + 1)
local pats = edit_recur_values()
local result = {}
for _, p in ipairs(pats) do
if p:find(after_colon, 1, true) == 1 then
table.insert(result, rec_prefix .. p)
end
end
return result
end
local cat_prefix = arg_lead:match('^(cat:)(.*)$')
if cat_prefix then
local after_colon = arg_lead:sub(#cat_prefix + 1)
local store = require('pending.store')
local s = store.new(store.resolve_path())
s:load()
local seen = {}
local cats = {}
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)
end
end
table.sort(cats)
local result = {}
for _, c in ipairs(cats) do
if c:find(after_colon, 1, true) == 1 then
table.insert(result, cat_prefix .. c)
end
end
return result
end
return filter_candidates(arg_lead, edit_field_candidates())
end
vim.api.nvim_create_user_command('Pending', function(opts)
require('pending').command(opts.args)
end, {
bar = true,
nargs = '*',
complete = function(arg_lead, cmd_line)
local subcmds = { 'add', 'sync', 'archive', 'due', 'undo' }
local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'init', 'sync', 'undo' }
if not cmd_line:match('^Pending%s+%S') then
return vim.tbl_filter(function(s)
return s:find(arg_lead, 1, true) == 1
end, subcmds)
return filter_candidates(arg_lead, subcmds)
end
if cmd_line:match('^Pending%s+filter') then
local after_filter = cmd_line:match('^Pending%s+filter%s+(.*)') or ''
local used = {}
for word in after_filter:gmatch('%S+') do
used[word] = true
end
local candidates = { 'clear', 'overdue', 'today', 'priority' }
local store = require('pending.store')
local s = store.new(store.resolve_path())
s:load()
local seen = {}
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)
end
end
local filtered = {}
for _, c in ipairs(candidates) do
if not used[c] and (arg_lead == '' or c:find(arg_lead, 1, true) == 1) then
table.insert(filtered, c)
end
end
return filtered
end
if cmd_line:match('^Pending%s+edit') then
return complete_edit(arg_lead, cmd_line)
end
if cmd_line:match('^Pending%s+sync') then
local after_sync = cmd_line:match('^Pending%s+sync%s+(.*)')
if not after_sync then
return {}
end
local parts = {}
for part in after_sync:gmatch('%S+') do
table.insert(parts, part)
end
local trailing_space = after_sync:match('%s$')
if #parts == 0 or (#parts == 1 and not trailing_space) then
local backends = {}
local pattern = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
for _, path in ipairs(pattern) do
local name = vim.fn.fnamemodify(path, ':t:r')
table.insert(backends, name)
end
table.sort(backends)
return filter_candidates(arg_lead, backends)
end
if #parts == 1 and trailing_space then
return filter_candidates(arg_lead, { 'auth', 'sync' })
end
if #parts >= 2 and not trailing_space then
return filter_candidates(arg_lead, { 'auth', 'sync' })
end
return {}
end
return {}
end,
@ -22,6 +234,10 @@ vim.keymap.set('n', '<Plug>(pending-open)', function()
require('pending').open()
end)
vim.keymap.set('n', '<Plug>(pending-close)', function()
require('pending.buffer').close()
end)
vim.keymap.set('n', '<Plug>(pending-toggle)', function()
require('pending').toggle_complete()
end)
@ -37,3 +253,65 @@ end)
vim.keymap.set('n', '<Plug>(pending-date)', function()
require('pending').prompt_date()
end)
vim.keymap.set('n', '<Plug>(pending-undo)', function()
require('pending').undo_write()
end)
vim.keymap.set('n', '<Plug>(pending-filter)', function()
vim.ui.input({ prompt = 'Filter: ' }, function(input)
if input then
require('pending').filter(input)
end
end)
end)
vim.keymap.set('n', '<Plug>(pending-open-line)', function()
require('pending.buffer').open_line(false)
end)
vim.keymap.set('n', '<Plug>(pending-open-line-above)', function()
require('pending.buffer').open_line(true)
end)
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-a-task)', function()
require('pending.textobj').a_task(vim.v.count1)
end)
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-i-task)', function()
require('pending.textobj').i_task(vim.v.count1)
end)
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-a-category)', function()
require('pending.textobj').a_category(vim.v.count1)
end)
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-i-category)', function()
require('pending.textobj').i_category(vim.v.count1)
end)
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-next-header)', function()
require('pending.textobj').next_header(vim.v.count1)
end)
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-prev-header)', function()
require('pending.textobj').prev_header(vim.v.count1)
end)
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-next-task)', function()
require('pending.textobj').next_task(vim.v.count1)
end)
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-prev-task)', function()
require('pending.textobj').prev_task(vim.v.count1)
end)
vim.keymap.set('n', '<Plug>(pending-tab)', function()
vim.cmd.tabnew()
require('pending').open()
end)
vim.api.nvim_create_user_command('PendingTab', function()
vim.cmd.tabnew()
require('pending').open()
end, {})

10
scripts/ci.sh Executable file
View file

@ -0,0 +1,10 @@
#!/bin/sh
set -eu
nix develop --command stylua --check .
git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet
nix develop --command prettier --check .
nix fmt
git diff --exit-code -- '*.nix'
nix develop --command lua-language-server --check lua --configpath "$(pwd)/.luarc.json" --checklevel=Warning
nix develop --command busted

30
scripts/demo-init.lua Normal file
View file

@ -0,0 +1,30 @@
vim.opt.runtimepath:prepend(vim.fn.getcwd())
local tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
}
local store = require('pending.store')
store.load()
local today = os.date('%Y-%m-%d')
local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
local tomorrow = os.date('%Y-%m-%d', os.time() + 86400)
store.add({
description = 'Finish quarterly report',
category = 'Work',
due = tomorrow,
recur = 'monthly',
priority = 1,
})
store.add({ description = 'Review pull requests', category = 'Work' })
store.add({ description = 'Update deployment docs', category = 'Work', status = 'done' })
store.add({ description = 'Buy groceries', category = 'Personal', due = today })
store.add({ description = 'Call dentist', category = 'Personal', due = yesterday, priority = 1 })
store.add({ description = 'Read chapter 5', category = 'Personal' })
store.add({ description = 'Learn a new language', category = 'Someday' })
store.add({ description = 'Plan hiking trip', category = 'Someday' })
store.save()

28
scripts/demo.tape Normal file
View file

@ -0,0 +1,28 @@
Output assets/demo.gif
Require nvim
Set Shell "bash"
Set FontSize 14
Set Width 900
Set Height 450
Type "nvim -u scripts/demo-init.lua -c 'autocmd VimEnter * Pending'"
Enter
Sleep 2s
Down
Down
Sleep 300ms
Down
Sleep 300ms
Enter
Sleep 500ms
Tab
Sleep 1s
Type "q"
Sleep 200ms

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)

173
spec/complete_spec.lua Normal file
View file

@ -0,0 +1,173 @@
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')
config.reset()
s = store.new(tmpdir .. '/tasks.json')
s:load()
buffer.set_store(s)
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
config.reset()
buffer.set_store(nil)
end)
describe('findstart', function()
it('returns column after colon for cat: prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task cat:Wo' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 })
local result = complete.omnifunc(1, '')
assert.are.equal(15, result)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('returns column after colon for due: prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task due:to' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 })
local result = complete.omnifunc(1, '')
assert.are.equal(15, result)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('returns column after colon for rec: prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task rec:we' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 })
local result = complete.omnifunc(1, '')
assert.are.equal(15, result)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('returns -1 for non-token position', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] some task ' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 14 })
local result = complete.omnifunc(1, '')
assert.are.equal(-1, result)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
end)
describe('completions', function()
it('returns existing categories for cat:', function()
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)
vim.api.nvim_win_set_cursor(0, { 1, 15 })
complete.omnifunc(1, '')
local result = complete.omnifunc(0, '')
local words = {}
for _, item in ipairs(result) do
table.insert(words, item.word)
end
assert.is_true(vim.tbl_contains(words, 'Work'))
assert.is_true(vim.tbl_contains(words, 'Home'))
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('filters categories by base', function()
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)
vim.api.nvim_win_set_cursor(0, { 1, 15 })
complete.omnifunc(1, '')
local result = complete.omnifunc(0, 'W')
assert.are.equal(1, #result)
assert.are.equal('Work', result[1].word)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('returns named dates for due:', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task due: x' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 15 })
complete.omnifunc(1, '')
local result = complete.omnifunc(0, '')
assert.is_true(#result > 0)
local words = {}
for _, item in ipairs(result) do
table.insert(words, item.word)
end
assert.is_true(vim.tbl_contains(words, 'today'))
assert.is_true(vim.tbl_contains(words, 'tomorrow'))
assert.is_true(vim.tbl_contains(words, 'eom'))
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('filters dates by base prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task due:to' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 })
complete.omnifunc(1, '')
local result = complete.omnifunc(0, 'to')
local words = {}
for _, item in ipairs(result) do
table.insert(words, item.word)
end
assert.is_true(vim.tbl_contains(words, 'today'))
assert.is_true(vim.tbl_contains(words, 'tomorrow'))
assert.is_false(vim.tbl_contains(words, 'eom'))
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('returns recurrence shorthands for rec:', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task rec: x' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 15 })
complete.omnifunc(1, '')
local result = complete.omnifunc(0, '')
assert.is_true(#result > 0)
local words = {}
for _, item in ipairs(result) do
table.insert(words, item.word)
end
assert.is_true(vim.tbl_contains(words, 'daily'))
assert.is_true(vim.tbl_contains(words, 'weekly'))
assert.is_true(vim.tbl_contains(words, '!weekly'))
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('filters recurrence by base prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task rec:we' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 })
complete.omnifunc(1, '')
local result = complete.omnifunc(0, 'we')
local words = {}
for _, item in ipairs(result) do
table.insert(words, item.word)
end
assert.is_true(vim.tbl_contains(words, 'weekly'))
assert.is_true(vim.tbl_contains(words, 'weekdays'))
assert.is_false(vim.tbl_contains(words, 'daily'))
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
end)
end)

View file

@ -1,35 +1,31 @@
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()
it('parses headers and tasks', function()
local lines = {
'## School',
'# School',
'/1/- [ ] Do homework',
'/2/- [!] Read chapter 5',
'',
'## Errands',
'# Errands',
'/3/- [ ] Buy groceries',
}
local result = diff.parse_buffer(lines)
@ -48,7 +44,7 @@ describe('diff', function()
it('handles new tasks without ids', function()
local lines = {
'## Inbox',
'# Inbox',
'- [ ] New task here',
}
local result = diff.parse_buffer(lines)
@ -60,7 +56,7 @@ describe('diff', function()
it('inline cat: token overrides header category', function()
local lines = {
'## Inbox',
'# Inbox',
'/1/- [ ] Buy milk cat:Work',
}
local result = diff.parse_buffer(lines)
@ -69,9 +65,28 @@ describe('diff', function()
assert.are.equal('Work', result[2].category)
end)
it('extracts rec: token from buffer line', function()
local lines = {
'# Inbox',
'/1/- [ ] Take trash out rec:weekly',
}
local result = diff.parse_buffer(lines)
assert.are.equal('weekly', result[2].rec)
end)
it('extracts rec: with completion mode', function()
local lines = {
'# Inbox',
'/1/- [ ] Water plants rec:!daily',
}
local result = diff.parse_buffer(lines)
assert.are.equal('daily', result[2].rec)
assert.are.equal('completion', result[2].rec_mode)
end)
it('inline due: token is parsed', function()
local lines = {
'## Inbox',
'# Inbox',
'/1/- [ ] Buy milk due:2026-03-15',
}
local result = diff.parse_buffer(lines)
@ -84,139 +99,179 @@ describe('diff', function()
describe('apply', function()
it('creates new tasks from buffer lines', function()
local lines = {
'## Inbox',
'# Inbox',
'- [ ] 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',
'# 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',
'# 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',
'# 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',
'# 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',
'# 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',
'# 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',
'# 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)
it('clears priority when [N] is removed from buffer line', function()
store.add({ description = 'Task name', priority = 1 })
store.save()
it('stores recur field on new tasks from buffer', function()
local lines = {
'## Inbox',
'# Inbox',
'- [ ] Take out trash rec:weekly',
}
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()
s:add({ description = 'Task', recur = 'daily' })
s:save()
local lines = {
'# Todo',
'/1/- [ ] Task rec:weekly',
}
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()
s:add({ description = 'Task', recur = 'daily' })
s:save()
local lines = {
'# Todo',
'/1/- [ ] Task',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.is_nil(task.recur)
end)
it('parses rec: with completion mode prefix', function()
local lines = {
'# Inbox',
'- [ ] Water plants rec:!weekly',
}
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()
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)

329
spec/edit_spec.lua Normal file
View file

@ -0,0 +1,329 @@
require('spec.helpers')
local config = require('pending.config')
describe('edit', function()
local tmpdir
local pending
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
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 s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'due:tomorrow')
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 }))
assert.are.equal(expected, updated.due)
end)
it('sets due date with literal YYYY-MM-DD', function()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'due:2026-06-15')
local updated = s:get(t.id)
assert.are.equal('2026-06-15', updated.due)
end)
it('sets category', function()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'cat:Work')
local updated = s:get(t.id)
assert.are.equal('Work', updated.category)
end)
it('adds priority', function()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), '+!')
local updated = s:get(t.id)
assert.are.equal(1, updated.priority)
end)
it('removes priority', function()
local s = pending.store()
local t = s:add({ description = 'Task one', priority = 1 })
s:save()
pending.edit(tostring(t.id), '-!')
local updated = s:get(t.id)
assert.are.equal(0, updated.priority)
end)
it('removes due date', function()
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 = s:get(t.id)
assert.is_nil(updated.due)
end)
it('removes category', function()
local s = pending.store()
local t = s:add({ description = 'Task one', category = 'Work' })
s:save()
pending.edit(tostring(t.id), '-cat')
local updated = s:get(t.id)
assert.is_nil(updated.category)
end)
it('sets recurrence', function()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'rec:weekly')
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 s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'rec:!daily')
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 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 = s:get(t.id)
assert.is_nil(updated.recur)
assert.is_nil(updated.recur_mode)
end)
it('applies multiple operations at once', function()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'due:today cat:Errands +!')
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 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, #s:undo_stack())
end)
it('persists changes to disk', function()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'cat:Work')
s:load()
local updated = s:get(t.id)
assert.are.equal('Work', updated.category)
end)
it('errors on unknown task ID', function()
local s = pending.store()
s:add({ description = 'Task one' })
s:save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit('999', 'cat:Work')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('No task with ID 999'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors on invalid date', function()
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)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), 'due:notadate')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Invalid date'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors on unknown operation token', function()
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)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), 'bogus')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Unknown operation'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors on invalid recurrence pattern', function()
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)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), 'rec:nope')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Invalid recurrence'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors when no operations given', function()
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)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), '')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Usage'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors when no id given', function()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit('', '')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Usage'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors on non-numeric id', function()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit('abc', 'cat:Work')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Invalid task ID'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('shows feedback message on success', function()
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)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), 'cat:Work')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Task #' .. t.id .. ' updated'))
assert.truthy(messages[1].msg:find('category set to Work'))
end)
it('respects custom date_syntax', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', date_syntax = 'by' }
config.reset()
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 = 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 }))
assert.are.equal(expected, updated.due)
end)
it('respects custom recur_syntax', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', recur_syntax = 'repeat' }
config.reset()
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 = s:get(t.id)
assert.are.equal('weekly', updated.recur)
end)
it('does not modify store on error', function()
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 = 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 s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'due:tomorrow@14:00')
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 }))
assert.are.equal(expected .. 'T14:00', updated.due)
end)
end)

292
spec/filter_spec.lua Normal file
View file

@ -0,0 +1,292 @@
require('spec.helpers')
local config = require('pending.config')
local diff = require('pending.diff')
describe('filter', function()
local tmpdir
local pending
local buffer
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
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()
package.loaded['pending'] = nil
package.loaded['pending.buffer'] = nil
end)
describe('filter predicates', function()
it('cat: hides tasks with non-matching category', function()
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 = s:active_tasks()
local work_task = nil
local home_task = nil
for _, t in ipairs(tasks) do
if t.category == 'Work' then
work_task = t
end
if t.category == 'Home' then
home_task = t
end
end
assert.is_not_nil(work_task)
assert.is_not_nil(home_task)
assert.is_nil(hidden[work_task.id])
assert.is_true(hidden[home_task.id])
end)
it('cat: hides tasks with no category (default category)', function()
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 = s:active_tasks()
local inbox_task = nil
for _, t in ipairs(tasks) do
if t.category ~= 'Work' then
inbox_task = t
end
end
assert.is_not_nil(inbox_task)
assert.is_true(hidden[inbox_task.id])
end)
it('overdue hides non-overdue tasks', function()
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 = s:active_tasks()
local overdue_task, future_task, nodue_task
for _, t in ipairs(tasks) do
if t.due == '2020-01-01' then
overdue_task = t
end
if t.due == '2099-01-01' then
future_task = t
end
if not t.due then
nodue_task = t
end
end
assert.is_nil(hidden[overdue_task.id])
assert.is_true(hidden[future_task.id])
assert.is_true(hidden[nodue_task.id])
end)
it('today hides non-today tasks', function()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
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 = s:active_tasks()
local today_task, old_task, future_task
for _, t in ipairs(tasks) do
if t.due == today then
today_task = t
end
if t.due == '2020-01-01' then
old_task = t
end
if t.due == '2099-01-01' then
future_task = t
end
end
assert.is_nil(hidden[today_task.id])
assert.is_true(hidden[old_task.id])
assert.is_true(hidden[future_task.id])
end)
it('priority hides non-priority tasks', function()
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 = s:active_tasks()
local important_task, normal_task
for _, t in ipairs(tasks) do
if t.priority and t.priority > 0 then
important_task = t
end
if not t.priority or t.priority == 0 then
normal_task = t
end
end
assert.is_nil(hidden[important_task.id])
assert.is_true(hidden[normal_task.id])
end)
it('multi-predicate AND: cat:Work + overdue', function()
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 = s:active_tasks()
local work_overdue, work_future, home_overdue
for _, t in ipairs(tasks) do
if t.description == 'Work overdue' then
work_overdue = t
end
if t.description == 'Work future' then
work_future = t
end
if t.description == 'Home overdue' then
home_overdue = t
end
end
assert.is_nil(hidden[work_overdue.id])
assert.is_true(hidden[work_future.id])
assert.is_true(hidden[home_overdue.id])
end)
it('filter clear removes all predicates and hidden ids', function()
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')
assert.are.equal(0, #buffer.filter_predicates())
assert.are.same({}, buffer.hidden_ids())
end)
it('filter empty string clears filter', function()
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('')
assert.are.equal(0, #buffer.filter_predicates())
end)
it('filter predicates persist across set_filter calls', function()
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 = s:active_tasks()
local home_task
for _, t in ipairs(tasks) do
if t.category == 'Home' then
home_task = t
end
end
assert.is_true(hidden[home_task.id])
end)
end)
describe('diff.apply with hidden_ids', function()
it('does not mark hidden tasks as deleted', function()
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
hidden_task = t
end
end
local hidden_ids = { [hidden_task.id] = true }
local lines = {
'/1/- [ ] Visible task',
}
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()
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
keep_task = t
end
if t.description == 'Delete task' then
delete_task = t
end
end
local lines = {
'/' .. keep_task.id .. '/- [ ] Keep task',
}
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()
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, s, {})
s:load()
local t = s:get(task.id)
assert.are.equal('pending', t.status)
end)
it('parse_buffer skips FILTER: header line', function()
local lines = {
'FILTER: overdue',
'/1/- [ ] A task',
}
local result = diff.parse_buffer(lines)
assert.are.equal(1, #result)
assert.are.equal('task', result[1].type)
assert.are.equal('A task', result[1].description)
end)
end)
end)

56
spec/icons_spec.lua Normal file
View file

@ -0,0 +1,56 @@
require('spec.helpers')
local config = require('pending.config')
describe('icons', function()
before_each(function()
vim.g.pending = nil
config.reset()
end)
after_each(function()
vim.g.pending = nil
config.reset()
end)
it('has default icon values', function()
local icons = config.get().icons
assert.equals(' ', icons.pending)
assert.equals('x', icons.done)
assert.equals('!', icons.priority)
assert.equals('.', icons.due)
assert.equals('~', icons.recur)
assert.equals('#', icons.category)
end)
it('allows overriding individual icons', function()
vim.g.pending = { icons = { pending = '*', done = '+' } }
config.reset()
local icons = config.get().icons
assert.equals('*', icons.pending)
assert.equals('+', icons.done)
assert.equals('!', icons.priority)
assert.equals('#', icons.category)
end)
it('allows overriding all icons', function()
vim.g.pending = {
icons = {
pending = '-',
done = '+',
priority = '*',
due = '@',
recur = '^',
category = '&',
},
}
config.reset()
local icons = config.get().icons
assert.equals('-', icons.pending)
assert.equals('+', icons.done)
assert.equals('*', icons.priority)
assert.equals('@', icons.due)
assert.equals('^', icons.recur)
assert.equals('&', icons.category)
end)
end)

View file

@ -154,6 +154,240 @@ describe('parse', function()
local result = parse.resolve_date('')
assert.is_nil(result)
end)
it("returns yesterday's date for 'yesterday'", function()
local expected = os.date('%Y-%m-%d', os.time() - 86400)
local result = parse.resolve_date('yesterday')
assert.are.equal(expected, result)
end)
it("returns today's date for 'eod'", function()
local result = parse.resolve_date('eod')
assert.are.equal(os.date('%Y-%m-%d'), result)
end)
it('returns Monday of current week for sow', function()
local result = parse.resolve_date('sow')
assert.is_not_nil(result)
local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$')
local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) })
local wday = os.date('*t', t).wday
assert.are.equal(2, wday)
end)
it('returns Sunday of current week for eow', function()
local result = parse.resolve_date('eow')
assert.is_not_nil(result)
local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$')
local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) })
local wday = os.date('*t', t).wday
assert.are.equal(1, wday)
end)
it('returns first day of current month for som', function()
local today = os.date('*t') --[[@as osdate]]
local expected = string.format('%04d-%02d-01', today.year, today.month)
local result = parse.resolve_date('som')
assert.are.equal(expected, result)
end)
it('returns last day of current month for eom', function()
local today = os.date('*t') --[[@as osdate]]
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 }))
local result = parse.resolve_date('eom')
assert.are.equal(expected, result)
end)
it('returns first day of current quarter for soq', function()
local today = os.date('*t') --[[@as osdate]]
local q = math.ceil(today.month / 3)
local first_month = (q - 1) * 3 + 1
local expected = string.format('%04d-%02d-01', today.year, first_month)
local result = parse.resolve_date('soq')
assert.are.equal(expected, result)
end)
it('returns last day of current quarter for eoq', function()
local today = os.date('*t') --[[@as osdate]]
local q = math.ceil(today.month / 3)
local last_month = q * 3
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 }))
local result = parse.resolve_date('eoq')
assert.are.equal(expected, result)
end)
it('returns Jan 1 of current year for soy', function()
local today = os.date('*t') --[[@as osdate]]
local expected = string.format('%04d-01-01', today.year)
local result = parse.resolve_date('soy')
assert.are.equal(expected, result)
end)
it('returns Dec 31 of current year for eoy', function()
local today = os.date('*t') --[[@as osdate]]
local expected = string.format('%04d-12-31', today.year)
local result = parse.resolve_date('eoy')
assert.are.equal(expected, result)
end)
it('resolves +2w to 14 days from today', function()
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 + 14 })
)
local result = parse.resolve_date('+2w')
assert.are.equal(expected, result)
end)
it('resolves +3m to 3 months from today', function()
local today = os.date('*t') --[[@as osdate]]
local expected = os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month + 3, day = today.day })
)
local result = parse.resolve_date('+3m')
assert.are.equal(expected, result)
end)
it('resolves -2d to 2 days ago', function()
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 - 2 })
)
local result = parse.resolve_date('-2d')
assert.are.equal(expected, result)
end)
it('resolves -1w to 7 days ago', function()
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 - 7 })
)
local result = parse.resolve_date('-1w')
assert.are.equal(expected, result)
end)
it("resolves 'later' to someday_date", function()
local result = parse.resolve_date('later')
assert.are.equal('9999-12-30', result)
end)
it("resolves 'someday' to someday_date", function()
local result = parse.resolve_date('someday')
assert.are.equal('9999-12-30', result)
end)
it('resolves 15th to next 15th of month', function()
local result = parse.resolve_date('15th')
assert.is_not_nil(result)
local _, _, d = result:match('^(%d+)-(%d+)-(%d+)$')
assert.are.equal('15', d)
end)
it('resolves 1st to next 1st of month', function()
local result = parse.resolve_date('1st')
assert.is_not_nil(result)
local _, _, d = result:match('^(%d+)-(%d+)-(%d+)$')
assert.are.equal('01', d)
end)
it('resolves jan to next January 1st', function()
local today = os.date('*t') --[[@as osdate]]
local result = parse.resolve_date('jan')
assert.is_not_nil(result)
local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$')
assert.are.equal('01', m)
assert.are.equal('01', d)
if today.month >= 1 then
assert.are.equal(tostring(today.year + 1), y)
end
end)
it('resolves dec to next December 1st', function()
local today = os.date('*t') --[[@as osdate]]
local result = parse.resolve_date('dec')
assert.is_not_nil(result)
local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$')
assert.are.equal('12', m)
assert.are.equal('01', d)
if today.month >= 12 then
assert.are.equal(tostring(today.year + 1), y)
else
assert.are.equal(tostring(today.year), y)
end
end)
end)
describe('resolve_date with time suffix', function()
local today = os.date('*t') --[[@as osdate]]
local tomorrow_str =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) --[[@as string]]
it('resolves bare hour to T09:00', function()
local result = parse.resolve_date('tomorrow@9')
assert.are.equal(tomorrow_str .. 'T09:00', result)
end)
it('resolves bare military hour to T14:00', function()
local result = parse.resolve_date('tomorrow@14')
assert.are.equal(tomorrow_str .. 'T14:00', result)
end)
it('resolves H:MM to T09:30', function()
local result = parse.resolve_date('tomorrow@9:30')
assert.are.equal(tomorrow_str .. 'T09:30', result)
end)
it('resolves HH:MM (existing format) to T09:30', function()
local result = parse.resolve_date('tomorrow@09:30')
assert.are.equal(tomorrow_str .. 'T09:30', result)
end)
it('resolves 2pm to T14:00', function()
local result = parse.resolve_date('tomorrow@2pm')
assert.are.equal(tomorrow_str .. 'T14:00', result)
end)
it('resolves 9am to T09:00', function()
local result = parse.resolve_date('tomorrow@9am')
assert.are.equal(tomorrow_str .. 'T09:00', result)
end)
it('resolves 9:30pm to T21:30', function()
local result = parse.resolve_date('tomorrow@9:30pm')
assert.are.equal(tomorrow_str .. 'T21:30', result)
end)
it('resolves 12am to T00:00', function()
local result = parse.resolve_date('tomorrow@12am')
assert.are.equal(tomorrow_str .. 'T00:00', result)
end)
it('resolves 12pm to T12:00', function()
local result = parse.resolve_date('tomorrow@12pm')
assert.are.equal(tomorrow_str .. 'T12:00', result)
end)
it('rejects hour 24', function()
assert.is_nil(parse.resolve_date('tomorrow@24'))
end)
it('rejects 13am', function()
assert.is_nil(parse.resolve_date('tomorrow@13am'))
end)
it('rejects minute 60', function()
assert.is_nil(parse.resolve_date('tomorrow@9:60'))
end)
it('rejects alphabetic garbage', function()
assert.is_nil(parse.resolve_date('tomorrow@abc'))
end)
end)
describe('command_add', function()

223
spec/recur_spec.lua Normal file
View file

@ -0,0 +1,223 @@
require('spec.helpers')
describe('recur', function()
local recur = require('pending.recur')
describe('parse', function()
it('parses daily', function()
local r = recur.parse('daily')
assert.are.equal('daily', r.freq)
assert.are.equal(1, r.interval)
assert.is_false(r.from_completion)
end)
it('parses weekdays', function()
local r = recur.parse('weekdays')
assert.are.equal('weekly', r.freq)
assert.are.same({ 'MO', 'TU', 'WE', 'TH', 'FR' }, r.byday)
end)
it('parses weekly', function()
local r = recur.parse('weekly')
assert.are.equal('weekly', r.freq)
assert.are.equal(1, r.interval)
end)
it('parses biweekly', function()
local r = recur.parse('biweekly')
assert.are.equal('weekly', r.freq)
assert.are.equal(2, r.interval)
end)
it('parses monthly', function()
local r = recur.parse('monthly')
assert.are.equal('monthly', r.freq)
assert.are.equal(1, r.interval)
end)
it('parses quarterly', function()
local r = recur.parse('quarterly')
assert.are.equal('monthly', r.freq)
assert.are.equal(3, r.interval)
end)
it('parses yearly', function()
local r = recur.parse('yearly')
assert.are.equal('yearly', r.freq)
assert.are.equal(1, r.interval)
end)
it('parses annual as yearly', function()
local r = recur.parse('annual')
assert.are.equal('yearly', r.freq)
end)
it('parses 3d as every 3 days', function()
local r = recur.parse('3d')
assert.are.equal('daily', r.freq)
assert.are.equal(3, r.interval)
end)
it('parses 2w as biweekly', function()
local r = recur.parse('2w')
assert.are.equal('weekly', r.freq)
assert.are.equal(2, r.interval)
end)
it('parses 6m as every 6 months', function()
local r = recur.parse('6m')
assert.are.equal('monthly', r.freq)
assert.are.equal(6, r.interval)
end)
it('parses 2y as every 2 years', function()
local r = recur.parse('2y')
assert.are.equal('yearly', r.freq)
assert.are.equal(2, r.interval)
end)
it('parses ! prefix as completion-based', function()
local r = recur.parse('!weekly')
assert.are.equal('weekly', r.freq)
assert.is_true(r.from_completion)
end)
it('parses raw RRULE fragment', function()
local r = recur.parse('FREQ=MONTHLY;BYDAY=1MO')
assert.is_not_nil(r)
end)
it('returns nil for invalid input', function()
assert.is_nil(recur.parse(''))
assert.is_nil(recur.parse('garbage'))
assert.is_nil(recur.parse('0d'))
end)
it('is case insensitive', function()
local r = recur.parse('Weekly')
assert.are.equal('weekly', r.freq)
end)
end)
describe('validate', function()
it('returns true for valid specs', function()
assert.is_true(recur.validate('daily'))
assert.is_true(recur.validate('2w'))
assert.is_true(recur.validate('!monthly'))
end)
it('returns false for invalid specs', function()
assert.is_false(recur.validate('garbage'))
assert.is_false(recur.validate(''))
end)
end)
describe('next_due', function()
it('advances daily by 1 day', function()
local result = recur.next_due('2099-03-01', 'daily', 'scheduled')
assert.are.equal('2099-03-02', result)
end)
it('advances weekly by 7 days', function()
local result = recur.next_due('2099-03-01', 'weekly', 'scheduled')
assert.are.equal('2099-03-08', result)
end)
it('advances monthly and clamps day', function()
local result = recur.next_due('2099-01-31', 'monthly', 'scheduled')
assert.are.equal('2099-02-28', result)
end)
it('advances yearly and handles leap year', function()
local result = recur.next_due('2096-02-29', 'yearly', 'scheduled')
assert.are.equal('2097-02-28', result)
end)
it('advances biweekly by 14 days', function()
local result = recur.next_due('2099-03-01', 'biweekly', 'scheduled')
assert.are.equal('2099-03-15', result)
end)
it('advances quarterly by 3 months', function()
local result = recur.next_due('2099-01-15', 'quarterly', 'scheduled')
assert.are.equal('2099-04-15', result)
end)
it('scheduled mode skips to future if overdue', function()
local result = recur.next_due('2020-01-01', 'yearly', 'scheduled')
local today = os.date('%Y-%m-%d') --[[@as string]]
assert.is_true(result > today)
end)
it('completion mode advances from today', function()
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 + 7,
})
)
local result = recur.next_due('2020-01-01', 'weekly', 'completion')
assert.are.equal(expected, result)
end)
it('advances 3d by 3 days', function()
local result = recur.next_due('2099-06-10', '3d', 'scheduled')
assert.are.equal('2099-06-13', result)
end)
end)
describe('to_rrule', function()
it('converts daily', function()
assert.are.equal('RRULE:FREQ=DAILY', recur.to_rrule('daily'))
end)
it('converts weekly', function()
assert.are.equal('RRULE:FREQ=WEEKLY', recur.to_rrule('weekly'))
end)
it('converts biweekly with interval', function()
assert.are.equal('RRULE:FREQ=WEEKLY;INTERVAL=2', recur.to_rrule('biweekly'))
end)
it('converts weekdays with BYDAY', function()
assert.are.equal('RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', recur.to_rrule('weekdays'))
end)
it('converts monthly', function()
assert.are.equal('RRULE:FREQ=MONTHLY', recur.to_rrule('monthly'))
end)
it('converts quarterly with interval', function()
assert.are.equal('RRULE:FREQ=MONTHLY;INTERVAL=3', recur.to_rrule('quarterly'))
end)
it('converts yearly', function()
assert.are.equal('RRULE:FREQ=YEARLY', recur.to_rrule('yearly'))
end)
it('converts 2w with interval', function()
assert.are.equal('RRULE:FREQ=WEEKLY;INTERVAL=2', recur.to_rrule('2w'))
end)
it('prefixes raw RRULE fragment', function()
assert.are.equal('RRULE:FREQ=MONTHLY;BYDAY=1MO', recur.to_rrule('FREQ=MONTHLY;BYDAY=1MO'))
end)
it('returns empty string for invalid spec', function()
assert.are.equal('', recur.to_rrule('garbage'))
end)
end)
describe('shorthand_list', function()
it('returns a list of named shorthands', function()
local list = recur.shorthand_list()
assert.is_true(#list >= 8)
assert.is_true(vim.tbl_contains(list, 'daily'))
assert.is_true(vim.tbl_contains(list, 'weekly'))
assert.is_true(vim.tbl_contains(list, 'monthly'))
end)
end)
end)

260
spec/status_spec.lua Normal file
View file

@ -0,0 +1,260 @@
require('spec.helpers')
local config = require('pending.config')
local parse = require('pending.parse')
describe('status', function()
local tmpdir
local pending
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
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)
describe('counts', function()
it('returns zeroes for empty store', function()
local c = pending.counts()
assert.are.equal(0, c.overdue)
assert.are.equal(0, c.today)
assert.are.equal(0, c.pending)
assert.are.equal(0, c.priority)
assert.is_nil(c.next_due)
end)
it('counts pending tasks', function()
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()
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()
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()
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()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
s:add({ description = 'Today task', due = today })
s:save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.today)
assert.are.equal(0, c.overdue)
end)
it('counts mixed overdue and today', function()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
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)
assert.are.equal(1, c.today)
end)
it('excludes done tasks', function()
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)
assert.are.equal(0, c.pending)
end)
it('excludes deleted tasks', function()
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)
assert.are.equal(0, c.pending)
end)
it('excludes someday sentinel', function()
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)
assert.are.equal(0, c.today)
assert.are.equal(1, c.pending)
end)
it('picks earliest future date as next_due', function()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
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)
end)
it('lazy loads on first counts() call', function()
local path = config.get().data_path
local f = io.open(path, 'w')
f:write(vim.json.encode({
version = 1,
next_id = 2,
tasks = {
{
id = 1,
description = 'Overdue',
status = 'pending',
due = '2020-01-01',
entry = '2020-01-01T00:00:00Z',
modified = '2020-01-01T00:00:00Z',
},
},
}))
f:close()
package.loaded['pending'] = nil
pending = require('pending')
local c = pending.counts()
assert.are.equal(1, c.overdue)
end)
end)
describe('statusline', function()
it('returns empty string when nothing actionable', function()
local s = pending.store()
s:save()
pending._recompute_counts()
assert.are.equal('', pending.statusline())
end)
it('formats overdue only', function()
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()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
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()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
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)
end)
describe('has_due', function()
it('returns false when nothing due', function()
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()
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()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
s:add({ description = 'Now', due = today })
s:save()
pending._recompute_counts()
assert.is_true(pending.has_due())
end)
end)
describe('parse.is_overdue', function()
it('date before today is overdue', function()
assert.is_true(parse.is_overdue('2020-01-01'))
end)
it('date after today is not overdue', function()
assert.is_false(parse.is_overdue('2099-01-01'))
end)
it('today date-only is not overdue', function()
local today = os.date('%Y-%m-%d') --[[@as string]]
assert.is_false(parse.is_overdue(today))
end)
end)
describe('parse.is_today', function()
it('today date-only is today', function()
local today = os.date('%Y-%m-%d') --[[@as string]]
assert.is_true(parse.is_today(today))
end)
it('yesterday is not today', function()
assert.is_false(parse.is_today('2020-01-01'))
end)
it('tomorrow is not today', function()
assert.is_false(parse.is_today('2099-01-01'))
end)
end)
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,22 +177,49 @@ 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()
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()
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()
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)
end)
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)
@ -210,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)

146
spec/sync_spec.lua Normal file
View file

@ -0,0 +1,146 @@
require('spec.helpers')
local config = require('pending.config')
describe('sync', function()
local tmpdir
local pending
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
package.loaded['pending'] = nil
pending = require('pending')
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
package.loaded['pending'] = nil
end)
describe('dispatch', function()
it('errors on bare :Pending sync with no backend', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.sync(nil)
vim.notify = orig
assert.are.equal('Usage: :Pending sync <backend> [action]', msg)
end)
it('errors on empty backend string', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.sync('')
vim.notify = orig
assert.are.equal('Usage: :Pending sync <backend> [action]', msg)
end)
it('errors on unknown backend', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.sync('notreal')
vim.notify = orig
assert.are.equal('Unknown sync backend: notreal', msg)
end)
it('errors on unknown action for valid backend', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.sync('gcal', 'notreal')
vim.notify = orig
assert.are.equal("gcal backend has no 'notreal' action", msg)
end)
it('defaults to sync action when action is omitted', function()
local called = false
local gcal = require('pending.sync.gcal')
local orig_sync = gcal.sync
gcal.sync = function()
called = true
end
pending.sync('gcal')
gcal.sync = orig_sync
assert.is_true(called)
end)
it('routes explicit sync action', function()
local called = false
local gcal = require('pending.sync.gcal')
local orig_sync = gcal.sync
gcal.sync = function()
called = true
end
pending.sync('gcal', 'sync')
gcal.sync = orig_sync
assert.is_true(called)
end)
it('routes auth action', function()
local called = false
local gcal = require('pending.sync.gcal')
local orig_auth = gcal.auth
gcal.auth = function()
called = true
end
pending.sync('gcal', 'auth')
gcal.auth = orig_auth
assert.is_true(called)
end)
end)
it('works with sync.gcal config', function()
config.reset()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
sync = { gcal = { calendar = 'NewStyle' } },
}
local cfg = config.get()
assert.are.equal('NewStyle', cfg.sync.gcal.calendar)
end)
describe('gcal module', function()
it('has name field', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('gcal', gcal.name)
end)
it('has auth function', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('function', type(gcal.auth))
end)
it('has sync function', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('function', type(gcal.sync))
end)
it('has health function', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('function', type(gcal.health))
end)
end)
end)

194
spec/textobj_spec.lua Normal file
View file

@ -0,0 +1,194 @@
require('spec.helpers')
local config = require('pending.config')
describe('textobj', function()
local textobj = require('pending.textobj')
before_each(function()
vim.g.pending = nil
config.reset()
end)
after_each(function()
vim.g.pending = nil
config.reset()
end)
describe('inner_task_range', function()
it('returns description range for task with id prefix', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries')
assert.are.equal(10, s)
assert.are.equal(22, e)
end)
it('returns description range for task without id prefix', function()
local s, e = textobj.inner_task_range('- [ ] Buy groceries')
assert.are.equal(7, s)
assert.are.equal(19, e)
end)
it('excludes trailing due: token', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries due:2026-03-15')
assert.are.equal(10, s)
assert.are.equal(22, e)
end)
it('excludes trailing cat: token', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries cat:Errands')
assert.are.equal(10, s)
assert.are.equal(22, e)
end)
it('excludes trailing rec: token', function()
local s, e = textobj.inner_task_range('/1/- [ ] Take out trash rec:weekly')
assert.are.equal(10, s)
assert.are.equal(23, e)
end)
it('excludes multiple trailing metadata tokens', function()
local s, e =
textobj.inner_task_range('/1/- [ ] Buy milk due:2026-03-15 cat:Errands rec:weekly')
assert.are.equal(10, s)
assert.are.equal(17, e)
end)
it('handles priority checkbox', function()
local s, e = textobj.inner_task_range('/1/- [!] Important task')
assert.are.equal(10, s)
assert.are.equal(23, e)
end)
it('handles done checkbox', function()
local s, e = textobj.inner_task_range('/1/- [x] Finished task')
assert.are.equal(10, s)
assert.are.equal(22, e)
end)
it('handles multi-digit task ids', function()
local s, e = textobj.inner_task_range('/123/- [ ] Some task')
assert.are.equal(12, s)
assert.are.equal(20, e)
end)
it('does not strip non-metadata tokens', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries for dinner')
assert.are.equal(10, s)
assert.are.equal(33, e)
end)
it('stops stripping at first non-metadata token from right', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries for dinner due:2026-03-15')
assert.are.equal(10, s)
assert.are.equal(33, e)
end)
it('respects custom date_syntax', function()
vim.g.pending = { date_syntax = 'by' }
config.reset()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries by:2026-03-15')
assert.are.equal(10, s)
assert.are.equal(22, e)
end)
it('respects custom recur_syntax', function()
vim.g.pending = { recur_syntax = 'repeat' }
config.reset()
local s, e = textobj.inner_task_range('/1/- [ ] Take trash repeat:weekly')
assert.are.equal(10, s)
assert.are.equal(19, e)
end)
it('handles task with only metadata after description', function()
local s, e = textobj.inner_task_range('/1/- [ ] X due:tomorrow')
assert.are.equal(10, s)
assert.are.equal(10, e)
end)
end)
describe('category_bounds', function()
it('returns header and last row for single category', function()
---@type pending.LineMeta[]
local meta = {
{ type = 'header', category = 'Work' },
{ type = 'task', id = 1 },
{ type = 'task', id = 2 },
}
local h, l = textobj.category_bounds(2, meta)
assert.are.equal(1, h)
assert.are.equal(3, l)
end)
it('returns bounds for first category with trailing blank', function()
---@type pending.LineMeta[]
local meta = {
{ type = 'header', category = 'Work' },
{ type = 'task', id = 1 },
{ type = 'blank' },
{ type = 'header', category = 'Personal' },
{ type = 'task', id = 2 },
}
local h, l = textobj.category_bounds(2, meta)
assert.are.equal(1, h)
assert.are.equal(3, l)
end)
it('returns bounds for second category', function()
---@type pending.LineMeta[]
local meta = {
{ type = 'header', category = 'Work' },
{ type = 'task', id = 1 },
{ type = 'blank' },
{ type = 'header', category = 'Personal' },
{ type = 'task', id = 2 },
{ type = 'task', id = 3 },
}
local h, l = textobj.category_bounds(5, meta)
assert.are.equal(4, h)
assert.are.equal(6, l)
end)
it('returns bounds when cursor is on header', function()
---@type pending.LineMeta[]
local meta = {
{ type = 'header', category = 'Work' },
{ type = 'task', id = 1 },
}
local h, l = textobj.category_bounds(1, meta)
assert.are.equal(1, h)
assert.are.equal(2, l)
end)
it('returns nil for blank line with no preceding header', function()
---@type pending.LineMeta[]
local meta = {
{ type = 'blank' },
{ type = 'header', category = 'Work' },
{ type = 'task', id = 1 },
}
local h, l = textobj.category_bounds(1, meta)
assert.is_nil(h)
assert.is_nil(l)
end)
it('returns nil for empty meta', function()
local h, l = textobj.category_bounds(1, {})
assert.is_nil(h)
assert.is_nil(l)
end)
it('includes blank between header and next header in bounds', function()
---@type pending.LineMeta[]
local meta = {
{ type = 'header', category = 'Work' },
{ type = 'task', id = 1 },
{ type = 'blank' },
{ type = 'header', category = 'Home' },
{ type = 'task', id = 2 },
}
local h, l = textobj.category_bounds(1, meta)
assert.are.equal(1, h)
assert.are.equal(3, l)
end)
end)
end)

View file

@ -5,39 +5,38 @@ 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())
assert.are.equal('## Work', lines[1])
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)
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 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
@ -204,12 +203,36 @@ describe('views', function()
assert.is_falsy(task_meta.overdue)
end)
it('includes recur in LineMeta for recurring tasks', function()
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
task_meta = m
end
end
assert.are.equal('weekly', task_meta.recur)
end)
it('has nil recur in LineMeta for non-recurring tasks', function()
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
task_meta = m
end
end
assert.is_nil(task_meta.recur)
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())
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
@ -220,47 +243,47 @@ describe('views', function()
end
end
end
assert.are.equal('## Work', first_header)
assert.are.equal('## Inbox', second_header)
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())
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
table.insert(headers, lines[i])
end
end
assert.are.equal('## Work', headers[1])
assert.are.equal('## Errands', headers[2])
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())
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
table.insert(headers, lines[i])
end
end
assert.are.equal('## Alpha', headers[1])
assert.are.equal('## Beta', headers[2])
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 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
@ -275,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
@ -292,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
@ -309,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
@ -326,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)
@ -343,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
@ -362,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
@ -375,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
@ -388,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
@ -399,5 +422,29 @@ describe('views', function()
end
assert.is_falsy(task_meta.overdue)
end)
it('includes recur in LineMeta for recurring tasks', function()
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
task_meta = m
end
end
assert.are.equal('daily', task_meta.recur)
end)
it('has nil recur in LineMeta for non-recurring tasks', function()
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
task_meta = m
end
end
assert.is_nil(task_meta.recur)
end)
end)
end)