Compare commits

...
Sign in to create a new pull request.

7 commits

Author SHA1 Message Date
18049e91a0 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.
2026-02-25 20:34:56 -05:00
66eb93a6d1 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.
2026-02-25 20:34:51 -05:00
ee2d125846 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.
2026-02-25 20:08:18 -05:00
c69afacc87 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:30:54 -05:00
9abf070912 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.
2026-02-25 13:38:18 -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
23 changed files with 2031 additions and 328 deletions

View file

@ -1,4 +1,4 @@
title: 'Q&A' title: "Q&A"
labels: [] labels: []
body: body:
- type: markdown - type: markdown

View file

@ -1,14 +1,13 @@
name: Bug Report name: Bug Report
description: Report a bug description: Report a bug
title: 'bug: ' title: "bug: "
labels: [bug] labels: [bug]
body: body:
- type: checkboxes - type: checkboxes
attributes: attributes:
label: Prerequisites label: Prerequisites
options: options:
- label: - label: I have searched [existing
I have searched [existing
issues](https://github.com/barrettruth/pending.nvim/issues) issues](https://github.com/barrettruth/pending.nvim/issues)
required: true required: true
- label: I have updated to the latest version - label: I have updated to the latest version
@ -16,16 +15,16 @@ body:
- type: textarea - type: textarea
attributes: attributes:
label: 'Neovim version' label: "Neovim version"
description: 'Output of `nvim --version`' description: "Output of `nvim --version`"
render: text render: text
validations: validations:
required: true required: true
- type: input - type: input
attributes: attributes:
label: 'Operating system' label: "Operating system"
placeholder: 'e.g. Arch Linux, macOS 15, Ubuntu 24.04' placeholder: "e.g. Arch Linux, macOS 15, Ubuntu 24.04"
validations: validations:
required: true required: true
@ -49,8 +48,8 @@ body:
- type: textarea - type: textarea
attributes: attributes:
label: 'Health check' label: "Health check"
description: 'Output of `:checkhealth task`' description: "Output of `:checkhealth task`"
render: text render: text
- type: textarea - type: textarea

View file

@ -1,14 +1,13 @@
name: Feature Request name: Feature Request
description: Suggest a feature description: Suggest a feature
title: 'feat: ' title: "feat: "
labels: [enhancement] labels: [enhancement]
body: body:
- type: checkboxes - type: checkboxes
attributes: attributes:
label: Prerequisites label: Prerequisites
options: options:
- label: - label: I have searched [existing
I have searched [existing
issues](https://github.com/barrettruth/pending.nvim/issues) issues](https://github.com/barrettruth/pending.nvim/issues)
required: true required: true

View file

@ -3,7 +3,7 @@ name: luarocks
on: on:
push: push:
tags: tags:
- 'v*' - "v*"
jobs: jobs:
quality: quality:

143
README.md
View file

@ -2,145 +2,30 @@
Edit tasks like text. `:w` saves them. Edit tasks like text. `:w` saves them.
A buffer-centric task manager for Neovim. Tasks live in a plain buffer — add <!-- insert preview -->
with `o`, delete with `dd`, reorder with `dd`/`p`, rename by editing. Write the
buffer and the diff is computed against a JSON store. No UI chrome, no floating
windows, no abstractions between you and your tasks.
## How it works ## Requirements
``` - Neovim 0.10+
School - (Optionally) `curl` and `openssl` for Google Calendar and Google Task sync
! Read chapter 5 Feb 28
Submit homework Feb 25
Errands ## Installation
Buy groceries Mar 01
Clean apartment
```
Category headers sit at column 0. Tasks are indented below them. `!` marks Install with your package manager of choice or via
priority. Due dates appear as right-aligned virtual text. Done tasks get [luarocks](https://luarocks.org/modules/barrettruth/pending.nvim):
strikethrough. Everything you see is editable buffer text — the IDs are
concealed, and metadata is parsed from inline syntax on save.
## Install
``` ```
luarocks install pending.nvim luarocks install pending.nvim
``` ```
**lazy.nvim:**
```lua
{ 'barrettruth/pending.nvim' }
```
Requires Neovim 0.10+. No external dependencies for local use. Google Calendar
sync requires `curl` and `openssl`.
## Usage
`:Pending` opens the task buffer. From there, it's just vim:
| Key | Action |
| --------- | ------------------------------- |
| `o` / `O` | Add a new task |
| `dd` | Delete a task (on `:w`) |
| `p` | Paste (duplicates get new IDs) |
| `:w` | Save all changes |
| `<CR>` | Toggle complete (immediate) |
| `<Tab>` | Switch category / priority view |
| `g?` | Show keybind help |
### Inline metadata
Type metadata tokens at the end of a task line before saving:
```
Buy milk due:2026-03-15 cat:Errands
```
On `:w`, the date and category are extracted. The description becomes `Buy milk`,
the due date renders as virtual text, and the task moves under the `Errands`
header.
### Quick add
```vim
:Pending add Buy groceries due:2026-03-15
:Pending add School: Submit homework
```
### Archive
```vim
:Pending archive " purge done tasks older than 30 days
:Pending archive 7 " purge done tasks older than 7 days
```
## Configuration
No `setup()` call required. Set `vim.g.pending` before the plugin loads:
```lua
vim.g.pending = {
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
default_view = 'category', -- 'category' or 'priority'
default_category = 'Inbox',
date_format = '%b %d', -- strftime format for virtual text
date_syntax = 'due', -- inline token name (e.g. 'by' for by:2026-03-15)
}
```
All fields are optional. Absent keys use the defaults shown above.
## Google Calendar sync
One-way push of tasks with due dates to a dedicated Google Calendar as all-day
events.
```lua
vim.g.pending = {
gcal = {
calendar = 'Tasks',
credentials_path = '/path/to/client_secret.json',
},
}
```
```vim
:Pending sync
```
On first run, a browser window opens for OAuth consent. The refresh token is
stored at `stdpath('data')/pending/gcal_tokens.json`. Completed or deleted tasks
have their calendar events removed. Due date changes update events in place.
## Mappings
The plugin defines `<Plug>` mappings for custom keybinds:
```lua
vim.keymap.set('n', '<leader>t', '<Plug>(pending-open)')
vim.keymap.set('n', '<leader>T', '<Plug>(pending-toggle)')
```
| Plug mapping | Action |
| -------------------------- | -------------------- |
| `<Plug>(pending-open)` | Open task buffer |
| `<Plug>(pending-toggle)` | Toggle complete |
| `<Plug>(pending-view)` | Switch view |
| `<Plug>(pending-priority)` | Toggle priority flag |
| `<Plug>(pending-date)` | Prompt for due date |
## Data format
Tasks are stored as JSON at `stdpath('data')/pending/tasks.json`. The schema is
versioned and forward-compatible — unknown fields are preserved on round-trip.
## Documentation ## Documentation
```vim ```vim
:checkhealth pending :help pending.nvim
``` ```
## Acknowledgements
- [dooing](https://github.com/atiladefreitas/dooing)
- [todo-comments.nvim](https://github.com/folke/todo-comments.nvim)
- [todotxt.nvim](https://github.com/arnarg/todotxt.nvim)

View file

@ -30,13 +30,16 @@ concealed tokens and are never visible during editing.
Features: ~ Features: ~
- Oil-style buffer editing: standard Vim motions for all task operations - Oil-style buffer editing: standard Vim motions for all task operations
- Inline metadata syntax: `due:` and `cat:` tokens parsed on `:w` - Inline metadata syntax: `due:`, `cat:`, and `rec:` tokens parsed on `:w`
- Relative date input: `today`, `tomorrow`, `+Nd`, weekday names - 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 priority flat list - Two views: category (default) and priority flat list
- Multi-level undo (up to 20 `:w` saves, session-only) - Multi-level undo (up to 20 `:w` saves, persisted across sessions)
- Quick-add from the command line with `:Pending add` - Quick-add from the command line with `:Pending add`
- Quickfix list of overdue/due-today tasks via `:Pending due` - Quickfix list of overdue/due-today tasks via `:Pending due`
- Foldable category sections (`zc`/`zo`) in category view - 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 - Google Calendar one-way push via OAuth PKCE
============================================================================== ==============================================================================
@ -95,20 +98,18 @@ parsed from the right and consumed until a non-metadata token is reached.
Supported tokens: ~ Supported tokens: ~
`due:YYYY-MM-DD` Set a due date using an absolute date. `due:YYYY-MM-DD` Set a due date using an absolute date.
`due:today` Resolve to today's date. `due:<name>` Resolve a named date (see |pending-dates| below).
`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. `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 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 `date_syntax` in |pending-config|. The token name for recurrence defaults to
`by:2026-03-15` instead. `rec` and is configurable via `recur_syntax`.
Example: > Example: >
Buy milk due:2026-03-15 cat:Errands 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 On `:w`, the description becomes `Buy milk`, the due date is stored as
@ -116,8 +117,104 @@ On `:w`, the description becomes `Buy milk`, the due date is stored as
placed under the `Errands` category header. placed under the `Errands` category header.
Parsing stops at the first token that is not a recognised metadata token. 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 Repeated tokens of the same type also stop parsing — only one `due:`, one
`cat:` per task line are consumed. `cat:`, and one `rec:` per task line are consumed.
Omnifunc completion is available for all three token types. In insert mode,
type `due:`, `cat:`, or `rec:` 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).
============================================================================== ==============================================================================
COMMANDS *pending-commands* COMMANDS *pending-commands*
@ -135,6 +232,7 @@ COMMANDS *pending-commands*
:Pending add Buy groceries due:2026-03-15 :Pending add Buy groceries due:2026-03-15
:Pending add School: Submit homework :Pending add School: Submit homework
:Pending add Errands: Pick up dry cleaning due:fri :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. If the buffer is currently open it is re-rendered after the add.
@ -161,7 +259,7 @@ COMMANDS *pending-commands*
:Pending undo :Pending undo
Undo the last `:w` save, restoring the task store to its previous state. 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 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.
============================================================================== ==============================================================================
MAPPINGS *pending-mappings* MAPPINGS *pending-mappings*
@ -169,27 +267,34 @@ MAPPINGS *pending-mappings*
The following keys are set buffer-locally when the task buffer opens. They The following keys are set buffer-locally when the task buffer opens. They
are active only in the `pending://` buffer. 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 ~ Key Action ~
------- ------------------------------------------------ ------- ------------------------------------------------
`<CR>` Toggle complete / uncomplete the task at cursor `q` Close the task buffer (`close`)
`!` Toggle the priority flag on the task at cursor `<CR>` Toggle complete / uncomplete (`toggle`)
`D` Prompt for a due date on the task at cursor `!` Toggle the priority flag (`priority`)
`<Tab>` Switch between category view and priority view `D` Prompt for a due date (`date`)
`U` Undo the last `:w` save `<Tab>` Switch between category / priority view (`view`)
`g?` Show a help popup with available keys `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) `zc` Fold the current category section (category view only)
`zo` Unfold 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 `dd`, `p`, `P`, and `:w` work as standard Vim operations.
at the position below or above the cursor rather than using standard Vim
indentation. `dd`, `p`, `P`, and `:w` work as expected.
*<Plug>(pending-open)* *<Plug>(pending-open)*
<Plug>(pending-open) <Plug>(pending-open)
Open the task buffer. Maps to |:Pending| with no arguments. 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)*
<Plug>(pending-toggle) <Plug>(pending-toggle)
Toggle complete / uncomplete for the task under the cursor. Toggle complete / uncomplete for the task under the cursor.
@ -206,6 +311,18 @@ indentation. `dd`, `p`, `P`, and `:w` work as expected.
<Plug>(pending-view) <Plug>(pending-view)
Switch between category view and priority view. Switch between category view and priority view.
*<Plug>(pending-undo)*
<Plug>(pending-undo)
Undo the last `:w` save.
*<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.
Example configuration: >lua Example configuration: >lua
vim.keymap.set('n', '<leader>t', '<Plug>(pending-open)') vim.keymap.set('n', '<leader>t', '<Plug>(pending-open)')
vim.keymap.set('n', '<leader>T', '<Plug>(pending-toggle)') vim.keymap.set('n', '<leader>T', '<Plug>(pending-toggle)')
@ -242,7 +359,19 @@ loads: >lua
default_category = 'Inbox', default_category = 'Inbox',
date_format = '%b %d', date_format = '%b %d',
date_syntax = 'due', date_syntax = 'due',
recur_syntax = 'rec',
someday_date = '9999-12-30',
category_order = {}, category_order = {},
keymaps = {
close = 'q',
toggle = '<CR>',
view = '<Tab>',
priority = '!',
date = 'D',
undo = 'U',
open_line = 'o',
open_line_above = 'O',
},
gcal = { gcal = {
calendar = 'Tasks', calendar = 'Tasks',
credentials_path = '/path/to/client_secret.json', credentials_path = '/path/to/client_secret.json',
@ -278,17 +407,46 @@ Fields: ~
this to use a different keyword, for example `'by'` this to use a different keyword, for example `'by'`
to write `by:2026-03-15` instead of `due:2026-03-15`. 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: {}) {category_order} (string[], default: {})
Ordered list of category names. In category view, Ordered list of category names. In category view,
categories that appear in this list are shown in the categories that appear in this list are shown in the
given order. Categories not in the list are appended given order. Categories not in the list are appended
after the ordered ones in their natural order. after the ordered ones in their natural order.
{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.
{gcal} (table, default: nil) {gcal} (table, default: nil)
Google Calendar sync configuration. See Google Calendar sync configuration. See
|pending.GcalConfig|. Omit this field entirely to |pending.GcalConfig|. Omit this field entirely to
disable Google Calendar sync. disable Google Calendar sync.
==============================================================================
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' },
},
},
})
<
============================================================================== ==============================================================================
GOOGLE CALENDAR *pending-gcal* GOOGLE CALENDAR *pending-gcal*
@ -371,6 +529,11 @@ PendingDone Applied to the text of completed tasks.
PendingPriority Applied to the `! ` priority marker on priority tasks. PendingPriority Applied to the `! ` priority marker on priority tasks.
Default: links to `DiagnosticWarn`. Default: links to `DiagnosticWarn`.
*PendingRecur*
PendingRecur Applied to the recurrence indicator virtual text shown
alongside due dates for recurring tasks.
Default: links to `DiagnosticInfo`.
To override a group in your colorscheme or config: >lua To override a group in your colorscheme or config: >lua
vim.api.nvim_set_hl(0, 'PendingDue', { fg = '#aaaaaa', italic = true }) vim.api.nvim_set_hl(0, 'PendingDue', { fg = '#aaaaaa', italic = true })
< <
@ -388,6 +551,7 @@ Checks performed: ~
category, date format, date syntax) category, date format, date syntax)
- Whether the data directory exists (warning if not yet created) - Whether the data directory exists (warning if not yet created)
- Whether the data file exists and can be parsed; reports total task count - Whether the data file exists and can be parsed; reports total task count
- Validates recurrence specs on stored tasks
- Whether `curl` is available (required for Google Calendar sync) - Whether `curl` is available (required for Google Calendar sync)
- Whether `openssl` is available (required for OAuth PKCE) - Whether `openssl` is available (required for OAuth PKCE)
@ -414,6 +578,8 @@ Task fields: ~
{category} (string) Category name. Defaults to `default_category`. {category} (string) Category name. Defaults to `default_category`.
{priority} (integer) `1` for priority tasks, `0` otherwise. {priority} (integer) `1` for priority tasks, `0` otherwise.
{due} (string) ISO date string `YYYY-MM-DD`, or absent. {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. {entry} (string) ISO 8601 UTC timestamp of creation.
{modified} (string) ISO 8601 UTC timestamp of last modification. {modified} (string) ISO 8601 UTC timestamp of last modification.
{end} (string) ISO 8601 UTC timestamp of completion or deletion. {end} (string) ISO 8601 UTC timestamp of completion or deletion.

View file

@ -37,10 +37,12 @@ function M.current_view_name()
return current_view return current_view
end end
---@return nil
function M.clear_winid() function M.clear_winid()
task_winid = nil task_winid = nil
end end
---@return nil
function M.close() function M.close()
if task_winid and vim.api.nvim_win_is_valid(task_winid) then if task_winid and vim.api.nvim_win_is_valid(task_winid) then
vim.api.nvim_win_close(task_winid, false) vim.api.nvim_win_close(task_winid, false)
@ -55,19 +57,13 @@ local function set_buf_options(bufnr)
vim.bo[bufnr].swapfile = false vim.bo[bufnr].swapfile = false
vim.bo[bufnr].filetype = 'pending' vim.bo[bufnr].filetype = 'pending'
vim.bo[bufnr].modifiable = true vim.bo[bufnr].modifiable = true
vim.bo[bufnr].omnifunc = 'v:lua.require("pending.complete").omnifunc'
end end
---@param winid integer ---@param winid integer
local function set_win_options(winid) local function set_win_options(winid)
vim.wo[winid].conceallevel = 3 vim.wo[winid].conceallevel = 3
vim.wo[winid].concealcursor = 'nvic' 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 vim.wo[winid].winfixheight = true
end end
@ -85,6 +81,7 @@ local function setup_syntax(bufnr)
end end
---@param above boolean ---@param above boolean
---@return nil
function M.open_line(above) function M.open_line(above)
local bufnr = task_bufnr local bufnr = task_bufnr
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
@ -122,24 +119,22 @@ local function apply_extmarks(bufnr, line_meta)
local row = i - 1 local row = i - 1
if m.type == 'task' then if m.type == 'task' then
local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue' local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue'
if m.show_category then local virt_parts = {}
local virt_text if m.show_category and m.category then
if m.category and m.due then table.insert(virt_parts, { m.category, 'PendingHeader' })
virt_text = { { m.category .. ' ', 'PendingHeader' }, { m.due, due_hl } }
elseif m.category then
virt_text = { { m.category, 'PendingHeader' } }
elseif m.due then
virt_text = { { m.due, due_hl } }
end end
if virt_text then if m.recur then
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { table.insert(virt_parts, { '\u{21bb} ' .. m.recur, 'PendingRecur' })
virt_text = virt_text, end
virt_text_pos = 'eol', if m.due then
}) table.insert(virt_parts, { 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 end
elseif m.due then
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { 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', virt_text_pos = 'eol',
}) })
end end
@ -167,6 +162,7 @@ local function setup_highlights()
vim.api.nvim_set_hl(0, 'PendingOverdue', { link = 'DiagnosticError', default = true }) 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, 'PendingDone', { link = 'Comment', default = true })
vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true }) vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true })
vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true })
end end
local function snapshot_folds(bufnr) local function snapshot_folds(bufnr)
@ -212,6 +208,7 @@ local function restore_folds(bufnr)
end end
---@param bufnr? integer ---@param bufnr? integer
---@return nil
function M.render(bufnr) function M.render(bufnr)
bufnr = bufnr or task_bufnr bufnr = bufnr or task_bufnr
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
@ -256,6 +253,7 @@ function M.render(bufnr)
restore_folds(bufnr) restore_folds(bufnr)
end end
---@return nil
function M.toggle_view() function M.toggle_view()
if current_view == 'category' then if current_view == 'category' then
current_view = 'priority' current_view = 'priority'

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

@ -0,0 +1,170 @@
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 store = require('pending.store')
local seen = {}
local result = {}
for _, task in ipairs(store.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

@ -2,14 +2,27 @@
---@field calendar? string ---@field calendar? string
---@field credentials_path? string ---@field credentials_path? string
---@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 open_line? string|false
---@field open_line_above? string|false
---@class pending.Config ---@class pending.Config
---@field data_path string ---@field data_path string
---@field default_view 'category'|'priority' ---@field default_view 'category'|'priority'
---@field default_category string ---@field default_category string
---@field date_format string ---@field date_format string
---@field date_syntax string ---@field date_syntax string
---@field recur_syntax string
---@field someday_date string
---@field category_order? string[] ---@field category_order? string[]
---@field drawer_height? integer ---@field drawer_height? integer
---@field keymaps pending.Keymaps
---@field gcal? pending.GcalConfig ---@field gcal? pending.GcalConfig
---@class pending.config ---@class pending.config
@ -22,7 +35,19 @@ local defaults = {
default_category = 'Todo', default_category = 'Todo',
date_format = '%b %d', date_format = '%b %d',
date_syntax = 'due', date_syntax = 'due',
recur_syntax = 'rec',
someday_date = '9999-12-30',
category_order = {}, category_order = {},
keymaps = {
close = 'q',
toggle = '<CR>',
view = '<Tab>',
priority = '!',
date = 'D',
undo = 'U',
open_line = 'o',
open_line_above = 'O',
},
} }
---@type pending.Config? ---@type pending.Config?
@ -38,6 +63,7 @@ function M.get()
return _resolved return _resolved
end end
---@return nil
function M.reset() function M.reset()
_resolved = nil _resolved = nil
end end

View file

@ -10,6 +10,8 @@ local store = require('pending.store')
---@field status? string ---@field status? string
---@field category? string ---@field category? string
---@field due? string ---@field due? string
---@field rec? string
---@field rec_mode? string
---@field lnum integer ---@field lnum integer
---@class pending.diff ---@class pending.diff
@ -48,6 +50,8 @@ function M.parse_buffer(lines)
status = status, status = status,
category = metadata.cat or current_category or config.get().default_category, category = metadata.cat or current_category or config.get().default_category,
due = metadata.due, due = metadata.due,
rec = metadata.rec,
rec_mode = metadata.rec_mode,
lnum = i, lnum = i,
}) })
end end
@ -61,6 +65,7 @@ function M.parse_buffer(lines)
end end
---@param lines string[] ---@param lines string[]
---@return nil
function M.apply(lines) function M.apply(lines)
local parsed = M.parse_buffer(lines) local parsed = M.parse_buffer(lines)
local now = timestamp() local now = timestamp()
@ -90,6 +95,8 @@ function M.apply(lines)
category = entry.category, category = entry.category,
priority = entry.priority, priority = entry.priority,
due = entry.due, due = entry.due,
recur = entry.rec,
recur_mode = entry.rec_mode,
order = order_counter, order = order_counter,
}) })
else else
@ -112,6 +119,14 @@ function M.apply(lines)
task.due = entry.due task.due = entry.due
changed = true changed = true
end 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 if entry.status and task.status ~= entry.status then
task.status = entry.status task.status = entry.status
if entry.status == 'done' then if entry.status == 'done' then
@ -135,6 +150,8 @@ function M.apply(lines)
category = entry.category, category = entry.category,
priority = entry.priority, priority = entry.priority,
due = entry.due, due = entry.due,
recur = entry.rec,
recur_mode = entry.rec_mode,
order = order_counter, order = order_counter,
}) })
end end

View file

@ -1,5 +1,6 @@
local M = {} local M = {}
---@return nil
function M.check() function M.check()
vim.health.start('pending.nvim') vim.health.start('pending.nvim')
@ -27,6 +28,17 @@ function M.check()
if load_ok then if load_ok then
local tasks = store.tasks() local tasks = store.tasks()
vim.health.ok('Data file loaded: ' .. #tasks .. ' 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 else
vim.health.error('Failed to load data file: ' .. tostring(err)) vim.health.error('Failed to load data file: ' .. tostring(err))
end end

View file

@ -6,8 +6,6 @@ local store = require('pending.store')
---@class pending.init ---@class pending.init
local M = {} local M = {}
---@type pending.Task[][]
local _undo_states = {}
local UNDO_MAX = 20 local UNDO_MAX = 20
---@return integer bufnr ---@return integer bufnr
@ -19,6 +17,7 @@ function M.open()
end end
---@param bufnr integer ---@param bufnr integer
---@return nil
function M._setup_autocmds(bufnr) function M._setup_autocmds(bufnr)
local group = vim.api.nvim_create_augroup('PendingBuffer', { clear = true }) local group = vim.api.nvim_create_augroup('PendingBuffer', { clear = true })
vim.api.nvim_create_autocmd('BufWriteCmd', { vim.api.nvim_create_autocmd('BufWriteCmd', {
@ -49,63 +48,76 @@ function M._setup_autocmds(bufnr)
end end
---@param bufnr integer ---@param bufnr integer
---@return nil
function M._setup_buf_mappings(bufnr) function M._setup_buf_mappings(bufnr)
local cfg = require('pending.config').get()
local km = cfg.keymaps
local opts = { buffer = bufnr, silent = true } local opts = { buffer = bufnr, silent = true }
vim.keymap.set('n', 'q', function()
---@type table<string, fun()>
local actions = {
close = function()
buffer.close() buffer.close()
end, opts) end,
vim.keymap.set('n', '<Esc>', function() toggle = function()
buffer.close()
end, opts)
vim.keymap.set('n', '<CR>', function()
M.toggle_complete() M.toggle_complete()
end, opts) end,
vim.keymap.set('n', '<Tab>', function() view = function()
buffer.toggle_view() buffer.toggle_view()
end, opts) end,
vim.keymap.set('n', 'g?', function() priority = function()
M.show_help()
end, opts)
vim.keymap.set('n', '!', function()
M.toggle_priority() M.toggle_priority()
end, opts) end,
vim.keymap.set('n', 'D', function() date = function()
M.prompt_date() M.prompt_date()
end, opts) end,
vim.keymap.set('n', 'U', function() undo = function()
M.undo_write() M.undo_write()
end, opts) end,
vim.keymap.set('n', 'o', function() open_line = function()
buffer.open_line(false) buffer.open_line(false)
end, opts) end,
vim.keymap.set('n', 'O', function() open_line_above = function()
buffer.open_line(true) buffer.open_line(true)
end, opts) 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
end end
---@param bufnr integer ---@param bufnr integer
---@return nil
function M._on_write(bufnr) function M._on_write(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local snapshot = store.snapshot() local snapshot = store.snapshot()
table.insert(_undo_states, snapshot) local stack = store.undo_stack()
if #_undo_states > UNDO_MAX then table.insert(stack, snapshot)
table.remove(_undo_states, 1) if #stack > UNDO_MAX then
table.remove(stack, 1)
end end
diff.apply(lines) diff.apply(lines)
buffer.render(bufnr) buffer.render(bufnr)
end end
---@return nil
function M.undo_write() function M.undo_write()
if #_undo_states == 0 then local stack = store.undo_stack()
if #stack == 0 then
vim.notify('Nothing to undo.', vim.log.levels.WARN) vim.notify('Nothing to undo.', vim.log.levels.WARN)
return return
end end
local state = table.remove(_undo_states) local state = table.remove(stack)
store.replace_tasks(state) store.replace_tasks(state)
store.save() store.save()
buffer.render(buffer.bufnr()) buffer.render(buffer.bufnr())
end end
---@return nil
function M.toggle_complete() function M.toggle_complete()
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
if not bufnr then if not bufnr then
@ -127,6 +139,19 @@ function M.toggle_complete()
if task.status == 'done' then if task.status == 'done' then
store.update(id, { status = 'pending', ['end'] = vim.NIL }) store.update(id, { status = 'pending', ['end'] = vim.NIL })
else else
if task.recur and task.due then
local recur = require('pending.recur')
local mode = task.recur_mode or 'scheduled'
local next_date = recur.next_due(task.due, task.recur, mode)
store.add({
description = task.description,
category = task.category,
priority = task.priority,
due = next_date,
recur = task.recur,
recur_mode = task.recur_mode,
})
end
store.update(id, { status = 'done' }) store.update(id, { status = 'done' })
end end
store.save() store.save()
@ -139,6 +164,7 @@ function M.toggle_complete()
end end
end end
---@return nil
function M.toggle_priority() function M.toggle_priority()
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
if not bufnr then if not bufnr then
@ -169,6 +195,7 @@ function M.toggle_priority()
end end
end end
---@return nil
function M.prompt_date() function M.prompt_date()
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
if not bufnr then if not bufnr then
@ -183,7 +210,7 @@ function M.prompt_date()
if not id then if not id then
return return
end 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 if not input then
return return
end end
@ -192,8 +219,11 @@ function M.prompt_date()
local resolved = parse.resolve_date(due) local resolved = parse.resolve_date(due)
if resolved then if resolved then
due = resolved due = resolved
elseif not due:match('^%d%d%d%d%-%d%d%-%d%d$') then elseif
vim.notify('Invalid date format. Use YYYY-MM-DD.', vim.log.levels.ERROR) 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 return
end end
end end
@ -204,6 +234,7 @@ function M.prompt_date()
end end
---@param text string ---@param text string
---@return nil
function M.add(text) function M.add(text)
if not text or text == '' then if not text or text == '' then
vim.notify('Usage: :Pending add <description>', vim.log.levels.ERROR) vim.notify('Usage: :Pending add <description>', vim.log.levels.ERROR)
@ -219,6 +250,8 @@ function M.add(text)
description = description, description = description,
category = metadata.cat, category = metadata.cat,
due = metadata.due, due = metadata.due,
recur = metadata.rec,
recur_mode = metadata.rec_mode,
}) })
store.save() store.save()
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
@ -228,6 +261,7 @@ function M.add(text)
vim.notify('Pending added: ' .. description) vim.notify('Pending added: ' .. description)
end end
---@return nil
function M.sync() function M.sync()
local ok, gcal = pcall(require, 'pending.sync.gcal') local ok, gcal = pcall(require, 'pending.sync.gcal')
if not ok then if not ok then
@ -238,6 +272,7 @@ function M.sync()
end end
---@param days? integer ---@param days? integer
---@return nil
function M.archive(days) function M.archive(days)
days = days or 30 days = days or 30
local cutoff = os.time() - (days * 86400) local cutoff = os.time() - (days * 86400)
@ -274,8 +309,46 @@ function M.archive(days)
end end
end end
function M.due() ---@param due string
---@return boolean
local function is_due_or_overdue(due)
local now = os.date('*t') --[[@as osdate]]
local today = os.date('%Y-%m-%d') --[[@as string]] 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
local function 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
---@return nil
function M.due()
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
local is_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr) local is_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr)
local meta = is_valid and buffer.meta() or nil local meta = is_valid and buffer.meta() or nil
@ -283,9 +356,9 @@ function M.due()
if meta and bufnr then if meta and bufnr then
for lnum, m in ipairs(meta) do 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 if m.type == 'task' and m.raw_due and m.status ~= 'done' and is_due_or_overdue(m.raw_due) then
local task = store.get(m.id or 0) local task = store.get(m.id or 0)
local label = m.raw_due < today and '[OVERDUE] ' or '[DUE] ' local label = is_overdue(m.raw_due) and '[OVERDUE] ' or '[DUE] '
table.insert(qf_items, { table.insert(qf_items, {
bufnr = bufnr, bufnr = bufnr,
lnum = lnum, lnum = lnum,
@ -297,8 +370,8 @@ function M.due()
else else
store.load() store.load()
for _, task in ipairs(store.active_tasks()) do for _, task in ipairs(store.active_tasks()) do
if task.status == 'pending' and task.due and task.due <= today then if task.status == 'pending' and task.due and is_due_or_overdue(task.due) then
local label = task.due < today and '[OVERDUE] ' or '[DUE] ' local label = is_overdue(task.due) and '[OVERDUE] ' or '[DUE] '
local text = label .. task.description local text = label .. task.description
if task.category then if task.category then
text = text .. ' [' .. task.category .. ']' text = text .. ' [' .. task.category .. ']'
@ -317,68 +390,8 @@ function M.due()
vim.cmd('copen') vim.cmd('copen')
end end
function M.show_help()
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 })
end
---@param args string ---@param args string
---@return nil
function M.command(args) function M.command(args)
if not args or args == '' then if not args or args == '' then
M.open() M.open()

View file

@ -24,11 +24,92 @@ local function is_valid_date(s)
return check.year == yn and check.month == mn and check.day == dn return check.year == yn and check.month == mn and check.day == dn
end 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 ---@return string
local function date_key() local function date_key()
return config.get().date_syntax or 'due' return config.get().date_syntax or 'due'
end end
---@return string
local function recur_key()
return config.get().recur_syntax or 'rec'
end
local weekday_map = { local weekday_map = {
sun = 1, sun = 1,
mon = 2, mon = 2,
@ -39,26 +120,160 @@ local weekday_map = {
sat = 7, sat = 7,
} }
---@param text string local month_map = {
---@return string|nil jan = 1,
function M.resolve_date(text) feb = 2,
local lower = text:lower() mar = 3,
local today = os.date('*t') --[[@as osdate]] apr = 4,
may = 5,
jun = 6,
jul = 7,
aug = 8,
sep = 9,
oct = 10,
nov = 11,
dec = 12,
}
if lower == 'today' then ---@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]] return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]]
end 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 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' 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 if lower == 'tomorrow' then
return os.date( 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', '%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + 1 }) os.time({ year = today.year, month = today.month, day = today.day + delta })
) --[[@as string]] ) --[[@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 end
local n = lower:match('^%+(%d+)d$') local n = lower:match('^%+(%d+)d$')
if n then if n then
return os.date( return append_time(
os.date(
'%Y-%m-%d', '%Y-%m-%d',
os.time({ os.time({
year = today.year, year = today.year,
@ -67,17 +282,133 @@ function M.resolve_date(text)
tonumber(n) --[[@as integer]] tonumber(n) --[[@as integer]]
), ),
}) })
) --[[@as string]] ) --[[@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 end
local target_wday = weekday_map[lower] local target_wday = weekday_map[lower]
if target_wday then if target_wday then
local current_wday = today.wday local current_wday = today.wday
local delta = (target_wday - current_wday) % 7 local delta = (target_wday - current_wday) % 7
return os.date( return append_time(
os.date(
'%Y-%m-%d', '%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + delta }) os.time({ year = today.year, month = today.month, day = today.day + delta })
) --[[@as string]] ) --[[@as string]],
time_suffix
)
end end
return nil return nil
@ -85,7 +416,7 @@ end
---@param text string ---@param text string
---@return string description ---@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) function M.body(text)
local tokens = {} local tokens = {}
for token in text:gmatch('%S+') do for token in text:gmatch('%S+') do
@ -95,8 +426,10 @@ function M.body(text)
local metadata = {} local metadata = {}
local i = #tokens local i = #tokens
local dk = date_key() 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 date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$'
local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$'
while i >= 1 do while i >= 1 do
local token = tokens[i] local token = tokens[i]
@ -105,7 +438,7 @@ function M.body(text)
if metadata.due then if metadata.due then
break break
end end
if not is_valid_date(due_val) then if not is_valid_datetime(due_val) then
break break
end end
metadata.due = due_val metadata.due = due_val
@ -131,8 +464,26 @@ function M.body(text)
metadata.cat = cat_val metadata.cat = cat_val
i = i - 1 i = i - 1
else else
local rec_val = token:match(rec_pattern)
if rec_val then
if metadata.rec then
break break
end 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
end end
end end
@ -148,7 +499,7 @@ end
---@param text string ---@param text string
---@return string description ---@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) function M.command_add(text)
local cat_prefix = text:match('^(%S.-):%s') local cat_prefix = text:match('^(%S.-):%s')
if cat_prefix then if cat_prefix then

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 category? string
---@field priority integer ---@field priority integer
---@field due? string ---@field due? string
---@field recur? string
---@field recur_mode? 'scheduled'|'completion'
---@field entry string ---@field entry string
---@field modified string ---@field modified string
---@field end? string ---@field end? string
@ -17,6 +19,7 @@ local config = require('pending.config')
---@field version integer ---@field version integer
---@field next_id integer ---@field next_id integer
---@field tasks pending.Task[] ---@field tasks pending.Task[]
---@field undo pending.Task[][]
---@class pending.store ---@class pending.store
local M = {} local M = {}
@ -32,6 +35,7 @@ local function empty_data()
version = SUPPORTED_VERSION, version = SUPPORTED_VERSION,
next_id = 1, next_id = 1,
tasks = {}, tasks = {},
undo = {},
} }
end end
@ -56,6 +60,8 @@ local known_fields = {
category = true, category = true,
priority = true, priority = true,
due = true, due = true,
recur = true,
recur_mode = true,
entry = true, entry = true,
modified = true, modified = true,
['end'] = true, ['end'] = true,
@ -81,6 +87,12 @@ local function task_to_table(task)
if task.due then if task.due then
t.due = task.due t.due = task.due
end 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 if task['end'] then
t['end'] = task['end'] t['end'] = task['end']
end end
@ -105,6 +117,8 @@ local function table_to_task(t)
category = t.category, category = t.category,
priority = t.priority or 0, priority = t.priority or 0,
due = t.due, due = t.due,
recur = t.recur,
recur_mode = t.recur_mode,
entry = t.entry, entry = t.entry,
modified = t.modified, modified = t.modified,
['end'] = t['end'], ['end'] = t['end'],
@ -153,13 +167,24 @@ function M.load()
version = decoded.version or SUPPORTED_VERSION, version = decoded.version or SUPPORTED_VERSION,
next_id = decoded.next_id or 1, next_id = decoded.next_id or 1,
tasks = {}, tasks = {},
undo = {},
} }
for _, t in ipairs(decoded.tasks or {}) do for _, t in ipairs(decoded.tasks or {}) do
table.insert(_data.tasks, table_to_task(t)) table.insert(_data.tasks, table_to_task(t))
end end
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(_data.undo, tasks)
end
end
return _data return _data
end end
---@return nil
function M.save() function M.save()
if not _data then if not _data then
return return
@ -170,10 +195,18 @@ function M.save()
version = _data.version, version = _data.version,
next_id = _data.next_id, next_id = _data.next_id,
tasks = {}, tasks = {},
undo = {},
} }
for _, task in ipairs(_data.tasks) do for _, task in ipairs(_data.tasks) do
table.insert(out.tasks, task_to_table(task)) table.insert(out.tasks, task_to_table(task))
end end
for _, snapshot in ipairs(_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 encoded = vim.json.encode(out)
local tmp = path .. '.tmp' local tmp = path .. '.tmp'
local f = io.open(tmp, 'w') local f = io.open(tmp, 'w')
@ -224,7 +257,7 @@ function M.get(id)
return nil return nil
end 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 ---@return pending.Task
function M.add(fields) function M.add(fields)
local data = M.data() local data = M.data()
@ -236,6 +269,8 @@ function M.add(fields)
category = fields.category or config.get().default_category, category = fields.category or config.get().default_category,
priority = fields.priority or 0, priority = fields.priority or 0,
due = fields.due, due = fields.due,
recur = fields.recur,
recur_mode = fields.recur_mode,
entry = now, entry = now,
modified = now, modified = now,
['end'] = nil, ['end'] = nil,
@ -286,6 +321,7 @@ function M.find_index(id)
end end
---@param tasks pending.Task[] ---@param tasks pending.Task[]
---@return nil
function M.replace_tasks(tasks) function M.replace_tasks(tasks)
M.data().tasks = tasks M.data().tasks = tasks
end end
@ -311,11 +347,24 @@ function M.snapshot()
return result return result
end end
---@return pending.Task[][]
function M.undo_stack()
return M.data().undo
end
---@param stack pending.Task[][]
---@return nil
function M.set_undo_stack(stack)
M.data().undo = stack
end
---@param id integer ---@param id integer
---@return nil
function M.set_next_id(id) function M.set_next_id(id)
M.data().next_id = id M.data().next_id = id
end end
---@return nil
function M.unload() function M.unload()
_data = nil _data = nil
end end

View file

@ -10,6 +10,7 @@ local config = require('pending.config')
---@field overdue? boolean ---@field overdue? boolean
---@field show_category? boolean ---@field show_category? boolean
---@field priority? integer ---@field priority? integer
---@field recur? string
---@class pending.views ---@class pending.views
local M = {} local M = {}
@ -20,7 +21,10 @@ local function format_due(due)
if not due then if not due then
return nil return nil
end 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 if not y then
return due return due
end end
@ -29,7 +33,30 @@ local function format_due(due)
month = tonumber(m) --[[@as integer]], month = tonumber(m) --[[@as integer]],
day = tonumber(d) --[[@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 due string
---@return boolean
local function 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 end
---@param tasks pending.Task[] ---@param tasks pending.Task[]
@ -73,7 +100,6 @@ end
---@return string[] lines ---@return string[] lines
---@return pending.LineMeta[] meta ---@return pending.LineMeta[] meta
function M.category_view(tasks) function M.category_view(tasks)
local today = os.date('%Y-%m-%d') --[[@as string]]
local by_cat = {} local by_cat = {}
local cat_order = {} local cat_order = {}
local cat_seen = {} local cat_seen = {}
@ -148,7 +174,8 @@ function M.category_view(tasks)
raw_due = task.due, raw_due = task.due,
status = task.status, status = task.status,
category = cat, 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 is_overdue(task.due) or nil,
recur = task.recur,
}) })
end end
end end
@ -160,7 +187,6 @@ end
---@return string[] lines ---@return string[] lines
---@return pending.LineMeta[] meta ---@return pending.LineMeta[] meta
function M.priority_view(tasks) function M.priority_view(tasks)
local today = os.date('%Y-%m-%d') --[[@as string]]
local pending = {} local pending = {}
local done = {} local done = {}
@ -198,8 +224,9 @@ function M.priority_view(tasks)
raw_due = task.due, raw_due = task.due,
status = task.status, status = task.status,
category = task.category, 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 is_overdue(task.due) or nil,
show_category = true, show_category = true,
recur = task.recur,
}) })
end end

View file

@ -6,6 +6,7 @@ vim.g.loaded_pending = true
vim.api.nvim_create_user_command('Pending', function(opts) vim.api.nvim_create_user_command('Pending', function(opts)
require('pending').command(opts.args) require('pending').command(opts.args)
end, { end, {
bar = true,
nargs = '*', nargs = '*',
complete = function(arg_lead, cmd_line) complete = function(arg_lead, cmd_line)
local subcmds = { 'add', 'sync', 'archive', 'due', 'undo' } local subcmds = { 'add', 'sync', 'archive', 'due', 'undo' }
@ -22,6 +23,10 @@ vim.keymap.set('n', '<Plug>(pending-open)', function()
require('pending').open() require('pending').open()
end) end)
vim.keymap.set('n', '<Plug>(pending-close)', function()
require('pending.buffer').close()
end)
vim.keymap.set('n', '<Plug>(pending-toggle)', function() vim.keymap.set('n', '<Plug>(pending-toggle)', function()
require('pending').toggle_complete() require('pending').toggle_complete()
end) end)
@ -37,3 +42,15 @@ end)
vim.keymap.set('n', '<Plug>(pending-date)', function() vim.keymap.set('n', '<Plug>(pending-date)', function()
require('pending').prompt_date() require('pending').prompt_date()
end) end)
vim.keymap.set('n', '<Plug>(pending-undo)', function()
require('pending').undo_write()
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)

171
spec/complete_spec.lua Normal file
View file

@ -0,0 +1,171 @@
require('spec.helpers')
local config = require('pending.config')
local store = require('pending.store')
describe('complete', function()
local tmpdir
local complete = require('pending.complete')
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
store.load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
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()
store.add({ description = 'A', category = 'Work' })
store.add({ description = 'B', category = 'Home' })
store.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()
store.add({ description = 'A', category = 'Work' })
store.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

@ -69,6 +69,25 @@ describe('diff', function()
assert.are.equal('Work', result[2].category) assert.are.equal('Work', result[2].category)
end) 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() it('inline due: token is parsed', function()
local lines = { local lines = {
'## Inbox', '## Inbox',
@ -206,6 +225,60 @@ describe('diff', function()
assert.is_nil(task.due) assert.is_nil(task.due)
end) end)
it('stores recur field on new tasks from buffer', function()
local lines = {
'## Inbox',
'- [ ] Take out trash rec:weekly',
}
diff.apply(lines)
store.unload()
store.load()
local tasks = store.active_tasks()
assert.are.equal(1, #tasks)
assert.are.equal('weekly', tasks[1].recur)
end)
it('updates recur field when changed inline', function()
store.add({ description = 'Task', recur = 'daily' })
store.save()
local lines = {
'## Todo',
'/1/- [ ] Task rec:weekly',
}
diff.apply(lines)
store.unload()
store.load()
local task = store.get(1)
assert.are.equal('weekly', task.recur)
end)
it('clears recur when token removed from line', function()
store.add({ description = 'Task', recur = 'daily' })
store.save()
local lines = {
'## Todo',
'/1/- [ ] Task',
}
diff.apply(lines)
store.unload()
store.load()
local task = store.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)
store.unload()
store.load()
local tasks = store.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() it('clears priority when [N] is removed from buffer line', function()
store.add({ description = 'Task name', priority = 1 }) store.add({ description = 'Task name', priority = 1 })
store.save() store.save()

View file

@ -154,6 +154,242 @@ describe('parse', function()
local result = parse.resolve_date('') local result = parse.resolve_date('')
assert.is_nil(result) assert.is_nil(result)
end) 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) end)
describe('command_add', function() 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)

View file

@ -196,6 +196,41 @@ describe('store', function()
end) end)
end) end)
describe('recurrence fields', function()
it('persists recur and recur_mode through round-trip', function()
store.load()
store.add({ description = 'Recurring', recur = 'weekly', recur_mode = 'scheduled' })
store.save()
store.unload()
store.load()
local task = store.get(1)
assert.are.equal('weekly', task.recur)
assert.are.equal('scheduled', task.recur_mode)
end)
it('persists recur without recur_mode', function()
store.load()
store.add({ description = 'Simple recur', recur = 'daily' })
store.save()
store.unload()
store.load()
local task = store.get(1)
assert.are.equal('daily', task.recur)
assert.is_nil(task.recur_mode)
end)
it('omits recur fields when not set', function()
store.load()
store.add({ description = 'No recur' })
store.save()
store.unload()
store.load()
local task = store.get(1)
assert.is_nil(task.recur)
assert.is_nil(task.recur_mode)
end)
end)
describe('active_tasks', function() describe('active_tasks', function()
it('excludes deleted tasks', function() it('excludes deleted tasks', function()
store.load() store.load()

View file

@ -204,6 +204,30 @@ describe('views', function()
assert.is_falsy(task_meta.overdue) assert.is_falsy(task_meta.overdue)
end) end)
it('includes recur in LineMeta for recurring tasks', function()
store.add({ description = 'Recurring', category = 'Inbox', recur = 'weekly' })
local _, meta = views.category_view(store.active_tasks())
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()
store.add({ description = 'Normal', category = 'Inbox' })
local _, meta = views.category_view(store.active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' then
task_meta = m
end
end
assert.is_nil(task_meta.recur)
end)
it('respects category_order when set', function() it('respects category_order when set', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work', 'Inbox' } } vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work', 'Inbox' } }
config.reset() config.reset()
@ -399,5 +423,29 @@ describe('views', function()
end end
assert.is_falsy(task_meta.overdue) assert.is_falsy(task_meta.overdue)
end) end)
it('includes recur in LineMeta for recurring tasks', function()
store.add({ description = 'Recurring', category = 'Inbox', recur = 'daily' })
local _, meta = views.priority_view(store.active_tasks())
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()
store.add({ description = 'Normal', category = 'Inbox' })
local _, meta = views.priority_view(store.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)
end) end)