feat: overdue highlighting, relative dates, undo write, buffer mappings (#1)
* feat(config): add category_order field Problem: category display order was always insertion order with no way to configure it. Solution: add category_order to config defaults so users can declare a preferred category ordering; unspecified categories append after. * feat(parse): add relative date resolution Problem: due dates required full YYYY-MM-DD input, adding friction for common cases like "today" or "next monday". Solution: add resolve_date() supporting today, tomorrow, +Nd, and weekday abbreviations; extend inline token parsing to resolve relative values before falling back to strict date validation. * feat(views): overdue flag, category in priority view, category ordering Problem: overdue tasks were visually indistinct from upcoming ones; priority view had no category context; category display order was not configurable. Solution: compute overdue meta flag for pending tasks past their due date; set show_category on priority view task meta; reorder categories according to config.category_order when present. * feat(buffer): overdue highlight, category virt text in priority view Problem: overdue tasks had no visual distinction; priority view showed no category context alongside due dates. Solution: add PendingOverdue highlight group; render category name as right-aligned virtual text in priority view, composited with the due date when both are present. * feat(init): undo write and buffer-local default mappings Problem: _undo_state was captured on every save but never consumed; toggle_priority and prompt_date had no buffer-local defaults, requiring manual <Plug> configuration. Solution: implement undo_write() to restore pre-save task state; add !, d, and U as buffer-local defaults following fugitive's philosophy of owning the buffer; expose :Pending undo as a command alias. * test(views): add views spec Problem: views.lua had no test coverage. Solution: add 26 tests covering category_view and priority_view including sort order, line format, overdue detection, show_category meta, and category_order config behavior. * test(archive): add archive spec Problem: archive had no test coverage. Solution: add 9 tests covering cutoff logic, custom day counts, pending task preservation, deleted task cleanup, and notify output. * docs: add vimdoc Problem: no :help documentation existed. Solution: add doc/pending.txt covering all features — commands, mappings, views, configuration, Google Calendar sync, highlight groups, data format, and health check — following standard vimdoc conventions. * ci: format * fix: resolve lint and type check errors Problem: selene flagged unused variables in new spec files; LuaLS flagged os.date/os.time return type mismatches, integer? assignments, and stale task.Task/task.GcalConfig type references. Solution: prefix unused spec variables with _ or drop unnecessary assignments; add --[[@as string/integer]] casts for os.date and os.time calls; add category_order field to pending.Config annotation; fix task.GcalConfig -> pending.GcalConfig and task.Task[] -> pending.Task[]; add nil guards on meta[row].id before store calls; cast store.data() return to non-optional. * ci: format * fix: sync * ci: format
This commit is contained in:
parent
7b97e9b840
commit
3a35fab6cf
11 changed files with 1137 additions and 30 deletions
415
doc/pending.txt
Normal file
415
doc/pending.txt
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
*pending.txt* Buffer-centric task management for Neovim
|
||||
|
||||
Author: Barrett Ruth <br.barrettruth@gmail.com>
|
||||
License: MIT
|
||||
|
||||
==============================================================================
|
||||
INTRODUCTION *pending.nvim*
|
||||
|
||||
pending.nvim is a buffer-centric task manager for Neovim. Tasks live in a
|
||||
plain, editable buffer — add with `o`, delete with `dd`, reorder with
|
||||
`dd`/`p`, rename by typing. Writing the buffer with `:w` computes a diff
|
||||
against the JSON store and applies only the changes. No floating windows,
|
||||
no special UI, no abstraction between you and your tasks.
|
||||
|
||||
The buffer looks like this: >
|
||||
|
||||
School
|
||||
! Read chapter 5 Feb 28
|
||||
Submit homework Feb 25
|
||||
|
||||
Errands
|
||||
Buy groceries Mar 01
|
||||
Clean apartment
|
||||
<
|
||||
|
||||
Category headers sit at column 0. Tasks are indented two spaces below them.
|
||||
`!` marks a priority task. Due dates appear as right-aligned virtual text.
|
||||
Completed tasks are rendered with strikethrough. Task IDs are embedded as
|
||||
concealed tokens and are never visible during editing.
|
||||
|
||||
Features: ~
|
||||
- Oil-style buffer editing: standard Vim motions for all task operations
|
||||
- Inline metadata syntax: `due:` and `cat:` tokens parsed on `:w`
|
||||
- Relative date input: `today`, `tomorrow`, `+Nd`, weekday names
|
||||
- Two views: category (default) and priority flat list
|
||||
- Single-level undo for the last `:w` save
|
||||
- Quick-add from the command line with `:Pending add`
|
||||
- Google Calendar one-way push via OAuth PKCE
|
||||
|
||||
==============================================================================
|
||||
REQUIREMENTS *pending-requirements*
|
||||
|
||||
- Neovim 0.10+
|
||||
- No external dependencies for local use
|
||||
- `curl` and `openssl` are required for Google Calendar sync
|
||||
|
||||
==============================================================================
|
||||
INSTALL *pending-install*
|
||||
|
||||
Install with lazy.nvim: >lua
|
||||
{ 'barrettruth/pending.nvim' }
|
||||
<
|
||||
|
||||
Install with luarocks: >vim
|
||||
luarocks install pending.nvim
|
||||
<
|
||||
|
||||
No `setup()` call is needed. The plugin loads automatically and works with
|
||||
defaults. To customize behavior, set |vim.g.pending| before the plugin loads.
|
||||
See |pending-config|.
|
||||
|
||||
==============================================================================
|
||||
USAGE *pending-usage*
|
||||
|
||||
Open the task buffer: >vim
|
||||
:Pending
|
||||
<
|
||||
|
||||
The buffer named `pending://` opens in the current window. From there, use
|
||||
standard Vim editing:
|
||||
|
||||
- `o` / `O` to add a new task line under or above the cursor
|
||||
- `dd` to remove a task (deletion is applied on `:w`)
|
||||
- `dd` + `p` to reorder tasks (pasted tasks receive new IDs)
|
||||
- `:w` to save — all additions, deletions, and edits are diffed against the
|
||||
store and committed atomically
|
||||
|
||||
Buffer-local keys are set automatically when the buffer opens. See
|
||||
|pending-mappings| for the full list.
|
||||
|
||||
The buffer uses `buftype=acwrite` so `:w` always routes through pending.nvim's
|
||||
write handler rather than writing to disk directly. The `pending://` buffer
|
||||
persists across window switches; reopening with `:Pending` focuses the
|
||||
existing window if one is open.
|
||||
|
||||
==============================================================================
|
||||
INLINE METADATA *pending-metadata*
|
||||
|
||||
Metadata tokens may be appended to any task line before saving. Tokens are
|
||||
parsed from the right and consumed until a non-metadata token is reached.
|
||||
|
||||
Supported tokens: ~
|
||||
|
||||
`due:YYYY-MM-DD` Set a due date using an absolute date.
|
||||
`due:today` Resolve to today's date.
|
||||
`due:tomorrow` Resolve to tomorrow's date.
|
||||
`due:+Nd` Resolve to N days from today (e.g. `due:+3d`).
|
||||
`due:mon` Resolve to the next occurrence of that weekday.
|
||||
Supported: `sun` `mon` `tue` `wed` `thu` `fri` `sat`
|
||||
`cat:Name` Move the task to the named category on save.
|
||||
|
||||
The token name for due dates defaults to `due` and is configurable via
|
||||
`date_syntax` in |pending-config|. If `date_syntax` is set to `by`, write
|
||||
`by:2026-03-15` instead.
|
||||
|
||||
Example: >
|
||||
|
||||
Buy milk due:2026-03-15 cat:Errands
|
||||
<
|
||||
|
||||
On `:w`, the description becomes `Buy milk`, the due date is stored as
|
||||
`2026-03-15` and rendered as right-aligned virtual text, and the task is
|
||||
placed under the `Errands` category header.
|
||||
|
||||
Parsing stops at the first token that is not a recognised metadata token.
|
||||
Repeated tokens of the same type also stop parsing — only one `due:` and one
|
||||
`cat:` per task line are consumed.
|
||||
|
||||
==============================================================================
|
||||
COMMANDS *pending-commands*
|
||||
|
||||
*:Pending*
|
||||
:Pending
|
||||
Open the task buffer. If the buffer is already displayed in a window,
|
||||
focus that window. Equivalent to |<Plug>(pending-open)|.
|
||||
|
||||
*:Pending-add*
|
||||
:Pending add {text}
|
||||
Quick-add a task without opening the buffer. Inline metadata tokens in
|
||||
{text} are parsed exactly as they are in the buffer. A `Category: ` prefix
|
||||
(uppercase first letter, colon, space) assigns the category directly: >vim
|
||||
:Pending add Buy groceries due:2026-03-15
|
||||
:Pending add School: Submit homework
|
||||
:Pending add Errands: Pick up dry cleaning due:fri
|
||||
<
|
||||
If the buffer is currently open it is re-rendered after the add.
|
||||
|
||||
*:Pending-archive*
|
||||
:Pending archive [{days}]
|
||||
Permanently remove done and deleted tasks whose completion timestamp is
|
||||
older than {days} days. {days} defaults to 30 if not provided. >vim
|
||||
:Pending archive " remove tasks completed more than 30 days ago
|
||||
:Pending archive 7 " remove tasks completed more than 7 days ago
|
||||
<
|
||||
|
||||
*:Pending-sync*
|
||||
:Pending sync
|
||||
Push pending tasks that have a due date to Google Calendar as all-day
|
||||
events. Requires |pending-gcal| to be configured. See |pending-gcal| for
|
||||
full details on what gets created, updated, and deleted.
|
||||
|
||||
*:Pending-undo*
|
||||
:Pending undo
|
||||
There is no `:Pending undo` subcommand. Use the `U` buffer-local key
|
||||
(see |pending-mappings|) to undo the last `:w` save while the task buffer
|
||||
is open.
|
||||
|
||||
==============================================================================
|
||||
MAPPINGS *pending-mappings*
|
||||
|
||||
The following keys are set buffer-locally when the task buffer opens. They
|
||||
are active only in the `pending://` buffer.
|
||||
|
||||
Buffer-local keys: ~
|
||||
|
||||
Key Action ~
|
||||
------- ------------------------------------------------
|
||||
`<CR>` Toggle complete / uncomplete the task at cursor
|
||||
`!` Toggle the priority flag on the task at cursor
|
||||
`d` Prompt for a due date on the task at cursor
|
||||
`<Tab>` Switch between category view and priority view
|
||||
`U` Undo the last `:w` save
|
||||
`g?` Show a help popup with available keys
|
||||
|
||||
Standard Vim keys `o`, `O`, `dd`, `p`, `P`, and `:w` work as expected.
|
||||
|
||||
*<Plug>(pending-open)*
|
||||
<Plug>(pending-open)
|
||||
Open the task buffer. Maps to |:Pending| with no arguments.
|
||||
|
||||
*<Plug>(pending-toggle)*
|
||||
<Plug>(pending-toggle)
|
||||
Toggle complete / uncomplete for the task under the cursor.
|
||||
|
||||
*<Plug>(pending-priority)*
|
||||
<Plug>(pending-priority)
|
||||
Toggle the priority flag for the task under the cursor.
|
||||
|
||||
*<Plug>(pending-date)*
|
||||
<Plug>(pending-date)
|
||||
Prompt for a due date for the task under the cursor.
|
||||
|
||||
*<Plug>(pending-view)*
|
||||
<Plug>(pending-view)
|
||||
Switch between category view and priority view.
|
||||
|
||||
Example configuration: >lua
|
||||
vim.keymap.set('n', '<leader>t', '<Plug>(pending-open)')
|
||||
vim.keymap.set('n', '<leader>T', '<Plug>(pending-toggle)')
|
||||
<
|
||||
|
||||
==============================================================================
|
||||
VIEWS *pending-views*
|
||||
|
||||
Two views are available. Switch with `<Tab>` or |<Plug>(pending-view)|.
|
||||
|
||||
Category view (default): ~ *pending-view-category*
|
||||
Tasks are grouped under their category header. Categories appear in the
|
||||
order tasks were added unless `category_order` is set (see
|
||||
|pending-config|). Blank lines separate categories. Within each category,
|
||||
pending tasks appear before done tasks. Priority tasks (`!`) are sorted
|
||||
first within each group.
|
||||
|
||||
Priority view: ~ *pending-view-priority*
|
||||
A flat list of all tasks sorted by priority, then by due date (tasks
|
||||
without a due date sort last), then by internal order. Done tasks appear
|
||||
after all pending tasks. Category names are shown as right-aligned virtual
|
||||
text alongside the due date virtual text so tasks remain identifiable
|
||||
across categories.
|
||||
|
||||
==============================================================================
|
||||
CONFIGURATION *pending-config*
|
||||
|
||||
Configuration is done via `vim.g.pending`. Set this before the plugin
|
||||
loads: >lua
|
||||
vim.g.pending = {
|
||||
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
|
||||
default_view = 'category',
|
||||
default_category = 'Inbox',
|
||||
date_format = '%b %d',
|
||||
date_syntax = 'due',
|
||||
category_order = {},
|
||||
gcal = {
|
||||
calendar = 'Tasks',
|
||||
credentials_path = '/path/to/client_secret.json',
|
||||
},
|
||||
}
|
||||
<
|
||||
|
||||
All fields are optional. Unset fields use the defaults shown above.
|
||||
|
||||
*pending.Config*
|
||||
Fields: ~
|
||||
{data_path} (string)
|
||||
Path to the JSON file where tasks are stored.
|
||||
Default: `stdpath('data') .. '/pending/tasks.json'`.
|
||||
The directory is created automatically on first save.
|
||||
|
||||
{default_view} ('category'|'priority', default: 'category')
|
||||
The view to use when the buffer is opened for the
|
||||
first time in a session.
|
||||
|
||||
{default_category} (string, default: 'Inbox')
|
||||
Category assigned to new tasks when no `cat:` token
|
||||
is present and no `Category: ` prefix is used with
|
||||
`:Pending add`.
|
||||
|
||||
{date_format} (string, default: '%b %d')
|
||||
strftime format string used to render due dates as
|
||||
virtual text in the buffer. Examples: `'%Y-%m-%d'`
|
||||
for ISO dates, `'%d %b'` for day-first.
|
||||
|
||||
{date_syntax} (string, default: 'due')
|
||||
The token name for inline due-date metadata. Change
|
||||
this to use a different keyword, for example `'by'`
|
||||
to write `by:2026-03-15` instead of `due:2026-03-15`.
|
||||
|
||||
{category_order} (string[], default: {})
|
||||
Ordered list of category names. In category view,
|
||||
categories that appear in this list are shown in the
|
||||
given order. Categories not in the list are appended
|
||||
after the ordered ones in their natural order.
|
||||
|
||||
{gcal} (table, default: nil)
|
||||
Google Calendar sync configuration. See
|
||||
|pending.GcalConfig|. Omit this field entirely to
|
||||
disable Google Calendar sync.
|
||||
|
||||
==============================================================================
|
||||
GOOGLE CALENDAR *pending-gcal*
|
||||
|
||||
pending.nvim can push tasks with due dates to a dedicated Google Calendar as
|
||||
all-day events. This is a one-way push; changes made in Google Calendar are
|
||||
not pulled back into pending.nvim.
|
||||
|
||||
Configuration: >lua
|
||||
vim.g.pending = {
|
||||
gcal = {
|
||||
calendar = 'Tasks',
|
||||
credentials_path = '/path/to/client_secret.json',
|
||||
},
|
||||
}
|
||||
<
|
||||
|
||||
*pending.GcalConfig*
|
||||
Fields: ~
|
||||
{calendar} (string, default: 'Pendings')
|
||||
Name of the Google Calendar to sync to. If a calendar
|
||||
with this name does not exist it is created
|
||||
automatically on the first sync.
|
||||
|
||||
{credentials_path} (string)
|
||||
Path to the OAuth client secret JSON file downloaded
|
||||
from the Google Cloud Console. Default:
|
||||
`stdpath('data')..'/pending/gcal_credentials.json'`.
|
||||
The file may be in the `installed` wrapper format
|
||||
that Google provides or as a bare credentials object.
|
||||
|
||||
OAuth flow: ~
|
||||
On the first `:Pending sync` call the plugin detects that no refresh token
|
||||
exists and opens the Google authorization URL in the browser using
|
||||
|vim.ui.open()|. A temporary local HTTP server listens on port 18392 for the
|
||||
OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used —
|
||||
`openssl` generates the code challenge. After the user grants consent, the
|
||||
authorization code is exchanged for tokens and the refresh token is stored at
|
||||
`stdpath('data')/pending/gcal_tokens.json` with mode `600`. Subsequent syncs
|
||||
use the stored refresh token and refresh the access token automatically when
|
||||
it is about to expire.
|
||||
|
||||
`:Pending sync` behavior: ~
|
||||
For each task in the store:
|
||||
- A pending task with a due date and no existing event: a new all-day event is
|
||||
created and the event ID is stored in the task's `_extra` table.
|
||||
- A pending task with a due date and an existing event: the event summary and
|
||||
date are updated in place.
|
||||
- A done or deleted task with an existing event: the event is deleted.
|
||||
- A pending task with no due date that had an existing event: the event is
|
||||
deleted.
|
||||
|
||||
A summary notification is shown after sync: `created: N, updated: N,
|
||||
deleted: N`.
|
||||
|
||||
==============================================================================
|
||||
HIGHLIGHT GROUPS *pending-highlights*
|
||||
|
||||
pending.nvim defines the following highlight groups. All groups are set with
|
||||
`default`, so colorschemes can override them by defining the group without
|
||||
`default` before or after the plugin loads.
|
||||
|
||||
*PendingHeader*
|
||||
PendingHeader Applied to category header lines (text at column 0).
|
||||
Default: bold.
|
||||
|
||||
*PendingDue*
|
||||
PendingDue Applied to the due date virtual text shown at the right
|
||||
margin of each task line.
|
||||
Default: fg=#888888, italic.
|
||||
|
||||
*PendingDone*
|
||||
PendingDone Applied to the text of completed tasks.
|
||||
Default: strikethrough, fg=#666666.
|
||||
|
||||
*PendingPriority*
|
||||
PendingPriority Applied to the `! ` priority marker on priority tasks.
|
||||
Default: fg=#e06c75, bold.
|
||||
|
||||
To override a group in your colorscheme or config: >lua
|
||||
vim.api.nvim_set_hl(0, 'PendingDue', { fg = '#aaaaaa', italic = true })
|
||||
<
|
||||
|
||||
==============================================================================
|
||||
HEALTH CHECK *pending-health*
|
||||
|
||||
Run |:checkhealth| pending to verify your setup: >vim
|
||||
:checkhealth pending
|
||||
<
|
||||
|
||||
Checks performed: ~
|
||||
- Config loads without error
|
||||
- Reports active configuration values (data path, default view, default
|
||||
category, date format, date syntax)
|
||||
- Whether the data directory exists (warning if not yet created)
|
||||
- Whether the data file exists and can be parsed; reports total task count
|
||||
- Whether `curl` is available (required for Google Calendar sync)
|
||||
- Whether `openssl` is available (required for OAuth PKCE)
|
||||
|
||||
==============================================================================
|
||||
DATA FORMAT *pending-data*
|
||||
|
||||
Tasks are stored as JSON at `data_path`. The file is safe to edit by hand and
|
||||
is forward-compatible — unknown fields are preserved on every read/write cycle
|
||||
via the `_extra` table.
|
||||
|
||||
Schema: >
|
||||
|
||||
{
|
||||
"version": 1,
|
||||
"next_id": 42,
|
||||
"tasks": [ ... ]
|
||||
}
|
||||
<
|
||||
|
||||
Task fields: ~
|
||||
{id} (integer) Unique, auto-incrementing task identifier.
|
||||
{description} (string) Task text as shown in the buffer.
|
||||
{status} (string) `'pending'`, `'done'`, or `'deleted'`.
|
||||
{category} (string) Category name. Defaults to `default_category`.
|
||||
{priority} (integer) `1` for priority tasks, `0` otherwise.
|
||||
{due} (string) ISO date string `YYYY-MM-DD`, or absent.
|
||||
{entry} (string) ISO 8601 UTC timestamp of creation.
|
||||
{modified} (string) ISO 8601 UTC timestamp of last modification.
|
||||
{end} (string) ISO 8601 UTC timestamp of completion or deletion.
|
||||
{order} (integer) Relative ordering within a category.
|
||||
|
||||
Any field not in the list above is preserved in `_extra` and written back on
|
||||
save. This is used internally to store the Google Calendar event ID
|
||||
(`_gcal_event_id`) and allows third-party tooling to annotate tasks without
|
||||
data loss.
|
||||
|
||||
The `version` field is checked on load. If the file version is newer than the
|
||||
version the plugin supports, loading is aborted with an error message asking
|
||||
you to update the plugin.
|
||||
|
||||
==============================================================================
|
||||
vim:tw=78:ts=8:ft=help:norl:
|
||||
|
|
@ -88,9 +88,25 @@ local function apply_extmarks(bufnr, line_meta)
|
|||
for i, m in ipairs(line_meta) do
|
||||
local row = i - 1
|
||||
if m.type == 'task' then
|
||||
if m.due then
|
||||
local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue'
|
||||
if m.show_category then
|
||||
local virt_text
|
||||
if m.category and m.due then
|
||||
virt_text = { { m.category .. ' ', 'PendingHeader' }, { m.due, due_hl } }
|
||||
elseif m.category then
|
||||
virt_text = { { m.category, 'PendingHeader' } }
|
||||
elseif m.due then
|
||||
virt_text = { { m.due, due_hl } }
|
||||
end
|
||||
if virt_text then
|
||||
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
|
||||
virt_text = virt_text,
|
||||
virt_text_pos = 'right_align',
|
||||
})
|
||||
end
|
||||
elseif m.due then
|
||||
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
|
||||
virt_text = { { m.due, 'PendingDue' } },
|
||||
virt_text = { { m.due, due_hl } },
|
||||
virt_text_pos = 'right_align',
|
||||
})
|
||||
end
|
||||
|
|
@ -120,6 +136,7 @@ local function setup_highlights()
|
|||
end
|
||||
hl('PendingHeader', { bold = true })
|
||||
hl('PendingDue', { fg = '#888888', italic = true })
|
||||
hl('PendingOverdue', { fg = '#e06c75', italic = true })
|
||||
hl('PendingDone', { strikethrough = true, fg = '#666666' })
|
||||
hl('PendingPriority', { fg = '#e06c75', bold = true })
|
||||
end
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
---@field default_category string
|
||||
---@field date_format string
|
||||
---@field date_syntax string
|
||||
---@field gcal? task.GcalConfig
|
||||
---@field category_order? string[]
|
||||
---@field gcal? pending.GcalConfig
|
||||
|
||||
---@class pending.config
|
||||
local M = {}
|
||||
|
|
@ -20,6 +21,7 @@ local defaults = {
|
|||
default_category = 'Inbox',
|
||||
date_format = '%b %d',
|
||||
date_syntax = 'due',
|
||||
category_order = {},
|
||||
}
|
||||
|
||||
---@type pending.Config?
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ local M = {}
|
|||
|
||||
---@return string
|
||||
local function timestamp()
|
||||
return os.date('!%Y-%m-%dT%H:%M:%SZ')
|
||||
return os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
|
||||
end
|
||||
|
||||
---@param lines string[]
|
||||
|
|
|
|||
|
|
@ -41,6 +41,15 @@ function M._setup_buf_mappings(bufnr)
|
|||
vim.keymap.set('n', 'g?', function()
|
||||
M.show_help()
|
||||
end, opts)
|
||||
vim.keymap.set('n', '!', function()
|
||||
M.toggle_priority()
|
||||
end, opts)
|
||||
vim.keymap.set('n', 'd', function()
|
||||
M.prompt_date()
|
||||
end, opts)
|
||||
vim.keymap.set('n', 'U', function()
|
||||
M.undo_write()
|
||||
end, opts)
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
|
|
@ -51,6 +60,17 @@ function M._on_write(bufnr)
|
|||
buffer.render(bufnr)
|
||||
end
|
||||
|
||||
function M.undo_write()
|
||||
if not _undo_state then
|
||||
vim.notify('Nothing to undo.', vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
store.replace_tasks(_undo_state)
|
||||
store.save()
|
||||
_undo_state = nil
|
||||
buffer.render(buffer.bufnr())
|
||||
end
|
||||
|
||||
function M.toggle_complete()
|
||||
local bufnr = buffer.bufnr()
|
||||
if not bufnr then
|
||||
|
|
@ -62,6 +82,9 @@ function M.toggle_complete()
|
|||
return
|
||||
end
|
||||
local id = meta[row].id
|
||||
if not id then
|
||||
return
|
||||
end
|
||||
local task = store.get(id)
|
||||
if not task then
|
||||
return
|
||||
|
|
@ -86,6 +109,9 @@ function M.toggle_priority()
|
|||
return
|
||||
end
|
||||
local id = meta[row].id
|
||||
if not id then
|
||||
return
|
||||
end
|
||||
local task = store.get(id)
|
||||
if not task then
|
||||
return
|
||||
|
|
@ -107,13 +133,19 @@ function M.prompt_date()
|
|||
return
|
||||
end
|
||||
local id = meta[row].id
|
||||
if not id then
|
||||
return
|
||||
end
|
||||
vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input)
|
||||
if not input then
|
||||
return
|
||||
end
|
||||
local due = input ~= '' and input or nil
|
||||
if due then
|
||||
if not due:match('^%d%d%d%d%-%d%d%-%d%d$') then
|
||||
local resolved = parse.resolve_date(due)
|
||||
if resolved then
|
||||
due = resolved
|
||||
elseif not due:match('^%d%d%d%d%-%d%d%-%d%d$') then
|
||||
vim.notify('Invalid date format. Use YYYY-MM-DD.', vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
|
@ -170,12 +202,12 @@ function M.archive(days)
|
|||
local y, mo, d, h, mi, s = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$')
|
||||
if y then
|
||||
local t = os.time({
|
||||
year = tonumber(y),
|
||||
month = tonumber(mo),
|
||||
day = tonumber(d),
|
||||
hour = tonumber(h),
|
||||
min = tonumber(mi),
|
||||
sec = tonumber(s),
|
||||
year = tonumber(y) --[[@as integer]],
|
||||
month = tonumber(mo) --[[@as integer]],
|
||||
day = tonumber(d) --[[@as integer]],
|
||||
hour = tonumber(h) --[[@as integer]],
|
||||
min = tonumber(mi) --[[@as integer]],
|
||||
sec = tonumber(s) --[[@as integer]],
|
||||
})
|
||||
if t < cutoff then
|
||||
archived = archived + 1
|
||||
|
|
@ -203,6 +235,9 @@ function M.show_help()
|
|||
'',
|
||||
'<CR> Toggle complete/uncomplete',
|
||||
'<Tab> Switch category/priority view',
|
||||
'! Toggle priority',
|
||||
'd Set due date',
|
||||
'U Undo last write',
|
||||
'o / O Add new task line',
|
||||
'dd Delete task (on :w)',
|
||||
'p / P Paste (duplicates get new IDs)',
|
||||
|
|
@ -212,6 +247,7 @@ function M.show_help()
|
|||
':Pending add Cat: <text> Quick-add with category',
|
||||
':Pending sync Push to Google Calendar',
|
||||
':Pending archive [days] Purge old done tasks',
|
||||
':Pending undo Undo last write',
|
||||
'',
|
||||
'Inline metadata (on new lines before :w):',
|
||||
' ' .. dk .. ':YYYY-MM-DD Set due date',
|
||||
|
|
@ -256,6 +292,8 @@ function M.command(args)
|
|||
elseif cmd == 'archive' then
|
||||
local d = rest ~= '' and tonumber(rest) or nil
|
||||
M.archive(d)
|
||||
elseif cmd == 'undo' then
|
||||
M.undo_write()
|
||||
else
|
||||
vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ local function is_valid_date(s)
|
|||
if not y then
|
||||
return false
|
||||
end
|
||||
y, m, d = tonumber(y), tonumber(m), tonumber(d)
|
||||
y, m, d =
|
||||
tonumber(y), --[[@as integer]]
|
||||
tonumber(m), --[[@as integer]]
|
||||
tonumber(d) --[[@as integer]]
|
||||
if m < 1 or m > 12 then
|
||||
return false
|
||||
end
|
||||
|
|
@ -27,6 +30,60 @@ local function date_key()
|
|||
return config.get().date_syntax or 'due'
|
||||
end
|
||||
|
||||
local weekday_map = {
|
||||
sun = 1,
|
||||
mon = 2,
|
||||
tue = 3,
|
||||
wed = 4,
|
||||
thu = 5,
|
||||
fri = 6,
|
||||
sat = 7,
|
||||
}
|
||||
|
||||
---@param text string
|
||||
---@return string|nil
|
||||
function M.resolve_date(text)
|
||||
local lower = text:lower()
|
||||
local today = os.date('*t')
|
||||
|
||||
if lower == 'today' then
|
||||
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]]
|
||||
end
|
||||
|
||||
if lower == 'tomorrow' then
|
||||
return os.date(
|
||||
'%Y-%m-%d',
|
||||
os.time({ year = today.year, month = today.month, day = today.day + 1 })
|
||||
) --[[@as string]]
|
||||
end
|
||||
|
||||
local n = lower:match('^%+(%d+)d$')
|
||||
if n then
|
||||
return os.date(
|
||||
'%Y-%m-%d',
|
||||
os.time({
|
||||
year = today.year,
|
||||
month = today.month,
|
||||
day = today.day + (
|
||||
tonumber(n) --[[@as integer]]
|
||||
),
|
||||
})
|
||||
) --[[@as string]]
|
||||
end
|
||||
|
||||
local target_wday = weekday_map[lower]
|
||||
if target_wday then
|
||||
local current_wday = today.wday
|
||||
local delta = (target_wday - current_wday) % 7
|
||||
return os.date(
|
||||
'%Y-%m-%d',
|
||||
os.time({ year = today.year, month = today.month, day = today.day + delta })
|
||||
) --[[@as string]]
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
---@param text string
|
||||
---@return string description
|
||||
---@return { due?: string, cat?: string } metadata
|
||||
|
|
@ -39,11 +96,12 @@ function M.body(text)
|
|||
local metadata = {}
|
||||
local i = #tokens
|
||||
local dk = date_key()
|
||||
local date_pattern = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$'
|
||||
local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$'
|
||||
local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$'
|
||||
|
||||
while i >= 1 do
|
||||
local token = tokens[i]
|
||||
local due_val = token:match(date_pattern)
|
||||
local due_val = token:match(date_pattern_strict)
|
||||
if due_val then
|
||||
if metadata.due then
|
||||
break
|
||||
|
|
@ -54,15 +112,28 @@ function M.body(text)
|
|||
metadata.due = due_val
|
||||
i = i - 1
|
||||
else
|
||||
local cat_val = token:match('^cat:(%S+)$')
|
||||
if cat_val then
|
||||
if metadata.cat then
|
||||
local raw_val = token:match(date_pattern_any)
|
||||
if raw_val then
|
||||
if metadata.due then
|
||||
break
|
||||
end
|
||||
metadata.cat = cat_val
|
||||
local resolved = M.resolve_date(raw_val)
|
||||
if not resolved then
|
||||
break
|
||||
end
|
||||
metadata.due = resolved
|
||||
i = i - 1
|
||||
else
|
||||
break
|
||||
local cat_val = token:match('^cat:(%S+)$')
|
||||
if cat_val then
|
||||
if metadata.cat then
|
||||
break
|
||||
end
|
||||
metadata.cat = cat_val
|
||||
i = i - 1
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ local config = require('pending.config')
|
|||
---@class pending.Data
|
||||
---@field version integer
|
||||
---@field next_id integer
|
||||
---@field tasks task.Task[]
|
||||
---@field tasks pending.Task[]
|
||||
|
||||
---@class pending.store
|
||||
local M = {}
|
||||
|
|
@ -45,7 +45,7 @@ end
|
|||
|
||||
---@return string
|
||||
local function timestamp()
|
||||
return os.date('!%Y-%m-%dT%H:%M:%SZ')
|
||||
return os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
|
||||
end
|
||||
|
||||
---@type table<string, true>
|
||||
|
|
@ -188,7 +188,7 @@ function M.data()
|
|||
if not _data then
|
||||
M.load()
|
||||
end
|
||||
return _data
|
||||
return _data --[[@as pending.Data]]
|
||||
end
|
||||
|
||||
---@return pending.Task[]
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@ local function find_or_create_calendar(access_token)
|
|||
return nil, err
|
||||
end
|
||||
|
||||
for _, item in ipairs(data.items or {}) do
|
||||
for _, item in ipairs(data and data.items or {}) do
|
||||
if item.summary == cal_name then
|
||||
return item.id, nil
|
||||
end
|
||||
|
|
@ -326,12 +326,13 @@ local function find_or_create_calendar(access_token)
|
|||
return nil, create_err
|
||||
end
|
||||
|
||||
return created.id, nil
|
||||
return created and created.id, nil
|
||||
end
|
||||
|
||||
local function next_day(date_str)
|
||||
local y, m, d = date_str:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
|
||||
local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) + 86400
|
||||
local t = os.time({ year = tonumber(y) or 0, month = tonumber(m) or 0, day = tonumber(d) or 0 })
|
||||
+ 86400
|
||||
return os.date('%Y-%m-%d', t)
|
||||
end
|
||||
|
||||
|
|
@ -354,7 +355,7 @@ local function create_event(access_token, calendar_id, task)
|
|||
if err then
|
||||
return nil, err
|
||||
end
|
||||
return data.id, nil
|
||||
return data and data.id, nil
|
||||
end
|
||||
|
||||
local function update_event(access_token, calendar_id, event_id, task)
|
||||
|
|
@ -416,7 +417,7 @@ function M.sync()
|
|||
else
|
||||
task._extra = extra
|
||||
end
|
||||
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ')
|
||||
task.modified = tostring(os.date('!%Y-%m-%dT%H:%M:%SZ'))
|
||||
deleted = deleted + 1
|
||||
end
|
||||
elseif task.status == 'pending' and task.due then
|
||||
|
|
@ -432,7 +433,7 @@ function M.sync()
|
|||
task._extra = {}
|
||||
end
|
||||
task._extra._gcal_event_id = new_id
|
||||
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ')
|
||||
task.modified = tostring(os.date('!%Y-%m-%dT%H:%M:%SZ'))
|
||||
created = created + 1
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ local config = require('pending.config')
|
|||
---@field raw_due? string
|
||||
---@field status? string
|
||||
---@field category? string
|
||||
---@field overdue? boolean
|
||||
---@field show_category? boolean
|
||||
|
||||
---@class pending.views
|
||||
local M = {}
|
||||
|
|
@ -21,8 +23,12 @@ local function format_due(due)
|
|||
if not y then
|
||||
return due
|
||||
end
|
||||
local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) })
|
||||
return os.date(config.get().date_format, t)
|
||||
local t = os.time({
|
||||
year = tonumber(y) --[[@as integer]],
|
||||
month = tonumber(m) --[[@as integer]],
|
||||
day = tonumber(d) --[[@as integer]],
|
||||
})
|
||||
return os.date(config.get().date_format, t) --[[@as string]]
|
||||
end
|
||||
|
||||
---@param tasks pending.Task[]
|
||||
|
|
@ -66,6 +72,7 @@ end
|
|||
---@return string[] lines
|
||||
---@return pending.LineMeta[] meta
|
||||
function M.category_view(tasks)
|
||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||
local by_cat = {}
|
||||
local cat_order = {}
|
||||
local cat_seen = {}
|
||||
|
|
@ -86,6 +93,24 @@ function M.category_view(tasks)
|
|||
end
|
||||
end
|
||||
|
||||
local cfg_order = config.get().category_order
|
||||
if cfg_order and #cfg_order > 0 then
|
||||
local ordered = {}
|
||||
local seen = {}
|
||||
for _, name in ipairs(cfg_order) do
|
||||
if cat_seen[name] then
|
||||
table.insert(ordered, name)
|
||||
seen[name] = true
|
||||
end
|
||||
end
|
||||
for _, name in ipairs(cat_order) do
|
||||
if not seen[name] then
|
||||
table.insert(ordered, name)
|
||||
end
|
||||
end
|
||||
cat_order = ordered
|
||||
end
|
||||
|
||||
for _, cat in ipairs(cat_order) do
|
||||
sort_tasks(by_cat[cat])
|
||||
sort_tasks(done_by_cat[cat])
|
||||
|
|
@ -123,6 +148,7 @@ function M.category_view(tasks)
|
|||
raw_due = task.due,
|
||||
status = task.status,
|
||||
category = cat,
|
||||
overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
|
@ -134,6 +160,7 @@ end
|
|||
---@return string[] lines
|
||||
---@return pending.LineMeta[] meta
|
||||
function M.priority_view(tasks)
|
||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||
local pending = {}
|
||||
local done = {}
|
||||
|
||||
|
|
@ -172,6 +199,8 @@ function M.priority_view(tasks)
|
|||
raw_due = task.due,
|
||||
status = task.status,
|
||||
category = task.category,
|
||||
overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil,
|
||||
show_category = true,
|
||||
})
|
||||
end
|
||||
|
||||
|
|
|
|||
131
spec/archive_spec.lua
Normal file
131
spec/archive_spec.lua
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
require('spec.helpers')
|
||||
|
||||
local config = require('pending.config')
|
||||
local store = require('pending.store')
|
||||
|
||||
describe('archive', function()
|
||||
local tmpdir
|
||||
local pending = require('pending')
|
||||
|
||||
before_each(function()
|
||||
tmpdir = vim.fn.tempname()
|
||||
vim.fn.mkdir(tmpdir, 'p')
|
||||
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
||||
config.reset()
|
||||
store.unload()
|
||||
store.load()
|
||||
end)
|
||||
|
||||
after_each(function()
|
||||
vim.fn.delete(tmpdir, 'rf')
|
||||
vim.g.pending = nil
|
||||
config.reset()
|
||||
end)
|
||||
|
||||
it('removes done tasks completed more than 30 days ago', function()
|
||||
local t = store.add({ description = 'Old done task' })
|
||||
store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
pending.archive()
|
||||
assert.are.equal(0, #store.active_tasks())
|
||||
end)
|
||||
|
||||
it('keeps done tasks completed fewer than 30 days ago', function()
|
||||
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
|
||||
local t = store.add({ description = 'Recent done task' })
|
||||
store.update(t.id, { status = 'done', ['end'] = recent_end })
|
||||
pending.archive()
|
||||
local active = store.active_tasks()
|
||||
assert.are.equal(1, #active)
|
||||
assert.are.equal('Recent done task', active[1].description)
|
||||
end)
|
||||
|
||||
it('respects a custom day count', function()
|
||||
local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400))
|
||||
local t = store.add({ description = 'Old for 7 days' })
|
||||
store.update(t.id, { status = 'done', ['end'] = eight_days_ago })
|
||||
pending.archive(7)
|
||||
assert.are.equal(0, #store.active_tasks())
|
||||
end)
|
||||
|
||||
it('keeps tasks within the custom day cutoff', function()
|
||||
local five_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
|
||||
local t = store.add({ description = 'Recent for 7 days' })
|
||||
store.update(t.id, { status = 'done', ['end'] = five_days_ago })
|
||||
pending.archive(7)
|
||||
local active = store.active_tasks()
|
||||
assert.are.equal(1, #active)
|
||||
end)
|
||||
|
||||
it('never archives pending tasks regardless of age', function()
|
||||
store.add({ description = 'Still pending' })
|
||||
pending.archive()
|
||||
local active = store.active_tasks()
|
||||
assert.are.equal(1, #active)
|
||||
assert.are.equal('pending', active[1].status)
|
||||
end)
|
||||
|
||||
it('removes deleted tasks past the cutoff', function()
|
||||
local t = store.add({ description = 'Old deleted task' })
|
||||
store.update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
pending.archive()
|
||||
local all = store.tasks()
|
||||
assert.are.equal(0, #all)
|
||||
end)
|
||||
|
||||
it('keeps deleted tasks within the cutoff', function()
|
||||
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
|
||||
local t = store.add({ description = 'Recent deleted' })
|
||||
store.update(t.id, { status = 'deleted', ['end'] = recent_end })
|
||||
pending.archive()
|
||||
local all = store.tasks()
|
||||
assert.are.equal(1, #all)
|
||||
end)
|
||||
|
||||
it('reports the correct count in vim.notify', function()
|
||||
local messages = {}
|
||||
local orig_notify = vim.notify
|
||||
vim.notify = function(msg, ...)
|
||||
table.insert(messages, msg)
|
||||
return orig_notify(msg, ...)
|
||||
end
|
||||
|
||||
local t1 = store.add({ description = 'Old 1' })
|
||||
local t2 = store.add({ description = 'Old 2' })
|
||||
store.add({ description = 'Keep' })
|
||||
store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
store.update(t2.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
|
||||
pending.archive()
|
||||
|
||||
vim.notify = orig_notify
|
||||
|
||||
local found = false
|
||||
for _, msg in ipairs(messages) do
|
||||
if msg:find('Archived 2') then
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
assert.is_true(found)
|
||||
end)
|
||||
|
||||
it('leaves only kept tasks in store.active_tasks after archive', function()
|
||||
local t1 = store.add({ description = 'Old done' })
|
||||
store.add({ description = 'Keep pending' })
|
||||
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
|
||||
local t3 = store.add({ description = 'Keep recent done' })
|
||||
store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
store.update(t3.id, { status = 'done', ['end'] = recent_end })
|
||||
|
||||
pending.archive()
|
||||
|
||||
local active = store.active_tasks()
|
||||
assert.are.equal(2, #active)
|
||||
local descs = {}
|
||||
for _, task in ipairs(active) do
|
||||
descs[task.description] = true
|
||||
end
|
||||
assert.is_true(descs['Keep pending'])
|
||||
assert.is_true(descs['Keep recent done'])
|
||||
end)
|
||||
end)
|
||||
403
spec/views_spec.lua
Normal file
403
spec/views_spec.lua
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
require('spec.helpers')
|
||||
|
||||
local config = require('pending.config')
|
||||
local store = require('pending.store')
|
||||
|
||||
describe('views', function()
|
||||
local tmpdir
|
||||
local views = require('pending.views')
|
||||
|
||||
before_each(function()
|
||||
tmpdir = vim.fn.tempname()
|
||||
vim.fn.mkdir(tmpdir, 'p')
|
||||
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
||||
config.reset()
|
||||
store.unload()
|
||||
store.load()
|
||||
end)
|
||||
|
||||
after_each(function()
|
||||
vim.fn.delete(tmpdir, 'rf')
|
||||
vim.g.pending = nil
|
||||
config.reset()
|
||||
end)
|
||||
|
||||
describe('category_view', function()
|
||||
it('groups tasks under their category header', function()
|
||||
store.add({ description = 'Task A', category = 'Work' })
|
||||
store.add({ description = 'Task B', category = 'Work' })
|
||||
local lines, meta = views.category_view(store.active_tasks())
|
||||
assert.are.equal('Work', lines[1])
|
||||
assert.are.equal('header', meta[1].type)
|
||||
assert.is_true(lines[2]:find('Task A') ~= nil)
|
||||
assert.is_true(lines[3]:find('Task B') ~= nil)
|
||||
end)
|
||||
|
||||
it('places pending tasks before done tasks within a category', function()
|
||||
local t1 = store.add({ description = 'Done task', category = 'Work' })
|
||||
store.add({ description = 'Pending task', category = 'Work' })
|
||||
store.update(t1.id, { status = 'done' })
|
||||
local _, meta = views.category_view(store.active_tasks())
|
||||
local pending_row, done_row
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.status == 'pending' then
|
||||
pending_row = i
|
||||
elseif m.type == 'task' and m.status == 'done' then
|
||||
done_row = i
|
||||
end
|
||||
end
|
||||
assert.is_true(pending_row < done_row)
|
||||
end)
|
||||
|
||||
it('sorts high-priority tasks before normal tasks within pending group', function()
|
||||
store.add({ description = 'Normal', category = 'Work', priority = 0 })
|
||||
store.add({ description = 'High', category = 'Work', priority = 1 })
|
||||
local lines, meta = views.category_view(store.active_tasks())
|
||||
local high_row, normal_row
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
local line = lines[i]
|
||||
if line:find('High') then
|
||||
high_row = i
|
||||
elseif line:find('Normal') then
|
||||
normal_row = i
|
||||
end
|
||||
end
|
||||
end
|
||||
assert.is_true(high_row < normal_row)
|
||||
end)
|
||||
|
||||
it('sorts high-priority tasks before normal tasks within done group', function()
|
||||
local t1 = store.add({ description = 'Done Normal', category = 'Work', priority = 0 })
|
||||
local t2 = store.add({ description = 'Done High', category = 'Work', priority = 1 })
|
||||
store.update(t1.id, { status = 'done' })
|
||||
store.update(t2.id, { status = 'done' })
|
||||
local lines, meta = views.category_view(store.active_tasks())
|
||||
local high_row, normal_row
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
local line = lines[i]
|
||||
if line:find('Done High') then
|
||||
high_row = i
|
||||
elseif line:find('Done Normal') then
|
||||
normal_row = i
|
||||
end
|
||||
end
|
||||
end
|
||||
assert.is_true(high_row < normal_row)
|
||||
end)
|
||||
|
||||
it('gives each category its own header with blank lines between them', function()
|
||||
store.add({ description = 'Task A', category = 'Work' })
|
||||
store.add({ description = 'Task B', category = 'Personal' })
|
||||
local lines, meta = views.category_view(store.active_tasks())
|
||||
local headers = {}
|
||||
local blank_found = false
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'header' then
|
||||
table.insert(headers, lines[i])
|
||||
elseif m.type == 'blank' then
|
||||
blank_found = true
|
||||
end
|
||||
end
|
||||
assert.are.equal(2, #headers)
|
||||
assert.is_true(blank_found)
|
||||
end)
|
||||
|
||||
it('formats task lines as /ID/ description', function()
|
||||
store.add({ description = 'My task', category = 'Inbox' })
|
||||
local lines, meta = views.category_view(store.active_tasks())
|
||||
local task_line
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
task_line = lines[i]
|
||||
end
|
||||
end
|
||||
assert.are.equal('/1/ My task', task_line)
|
||||
end)
|
||||
|
||||
it('formats priority task lines as /ID/ ! description', function()
|
||||
store.add({ description = 'Important', category = 'Inbox', priority = 1 })
|
||||
local lines, meta = views.category_view(store.active_tasks())
|
||||
local task_line
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
task_line = lines[i]
|
||||
end
|
||||
end
|
||||
assert.are.equal('/1/ ! Important', task_line)
|
||||
end)
|
||||
|
||||
it('sets LineMeta type=header for header lines with correct category', function()
|
||||
store.add({ description = 'T', category = 'School' })
|
||||
local _, meta = views.category_view(store.active_tasks())
|
||||
assert.are.equal('header', meta[1].type)
|
||||
assert.are.equal('School', meta[1].category)
|
||||
end)
|
||||
|
||||
it('sets LineMeta type=task with correct id and status', function()
|
||||
local t = store.add({ description = 'Do something', category = 'Inbox' })
|
||||
local _, meta = views.category_view(store.active_tasks())
|
||||
local task_meta
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
task_meta = m
|
||||
end
|
||||
end
|
||||
assert.are.equal('task', task_meta.type)
|
||||
assert.are.equal(t.id, task_meta.id)
|
||||
assert.are.equal('pending', task_meta.status)
|
||||
end)
|
||||
|
||||
it('sets LineMeta type=blank for blank separator lines', function()
|
||||
store.add({ description = 'A', category = 'Work' })
|
||||
store.add({ description = 'B', category = 'Home' })
|
||||
local _, meta = views.category_view(store.active_tasks())
|
||||
local blank_meta
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'blank' then
|
||||
blank_meta = m
|
||||
break
|
||||
end
|
||||
end
|
||||
assert.is_not_nil(blank_meta)
|
||||
assert.are.equal('blank', blank_meta.type)
|
||||
end)
|
||||
|
||||
it('marks overdue pending tasks with meta.overdue=true', function()
|
||||
local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
|
||||
local t = store.add({ description = 'Overdue task', category = 'Inbox', due = yesterday })
|
||||
local _, meta = views.category_view(store.active_tasks())
|
||||
local task_meta
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.id == t.id then
|
||||
task_meta = m
|
||||
end
|
||||
end
|
||||
assert.is_true(task_meta.overdue == true)
|
||||
end)
|
||||
|
||||
it('does not mark future pending tasks as overdue', function()
|
||||
local tomorrow = os.date('%Y-%m-%d', os.time() + 86400)
|
||||
local t = store.add({ description = 'Future task', category = 'Inbox', due = tomorrow })
|
||||
local _, meta = views.category_view(store.active_tasks())
|
||||
local task_meta
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.id == t.id then
|
||||
task_meta = m
|
||||
end
|
||||
end
|
||||
assert.is_falsy(task_meta.overdue)
|
||||
end)
|
||||
|
||||
it('does not mark done tasks with overdue due dates as overdue', function()
|
||||
local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
|
||||
local t = store.add({ description = 'Done late', category = 'Inbox', due = yesterday })
|
||||
store.update(t.id, { status = 'done' })
|
||||
local _, meta = views.category_view(store.active_tasks())
|
||||
local task_meta
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.id == t.id then
|
||||
task_meta = m
|
||||
end
|
||||
end
|
||||
assert.is_falsy(task_meta.overdue)
|
||||
end)
|
||||
|
||||
it('respects category_order when set', function()
|
||||
vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work', 'Inbox' } }
|
||||
config.reset()
|
||||
store.add({ description = 'Inbox task', category = 'Inbox' })
|
||||
store.add({ description = 'Work task', category = 'Work' })
|
||||
local lines, meta = views.category_view(store.active_tasks())
|
||||
local first_header, second_header
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'header' then
|
||||
if not first_header then
|
||||
first_header = lines[i]
|
||||
else
|
||||
second_header = lines[i]
|
||||
end
|
||||
end
|
||||
end
|
||||
assert.are.equal('Work', first_header)
|
||||
assert.are.equal('Inbox', second_header)
|
||||
end)
|
||||
|
||||
it('appends categories not in category_order after ordered ones', function()
|
||||
vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work' } }
|
||||
config.reset()
|
||||
store.add({ description = 'Errand', category = 'Errands' })
|
||||
store.add({ description = 'Work task', category = 'Work' })
|
||||
local lines, meta = views.category_view(store.active_tasks())
|
||||
local headers = {}
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'header' then
|
||||
table.insert(headers, lines[i])
|
||||
end
|
||||
end
|
||||
assert.are.equal('Work', headers[1])
|
||||
assert.are.equal('Errands', headers[2])
|
||||
end)
|
||||
|
||||
it('preserves insertion order when category_order is empty', function()
|
||||
store.add({ description = 'Alpha task', category = 'Alpha' })
|
||||
store.add({ description = 'Beta task', category = 'Beta' })
|
||||
local lines, meta = views.category_view(store.active_tasks())
|
||||
local headers = {}
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'header' then
|
||||
table.insert(headers, lines[i])
|
||||
end
|
||||
end
|
||||
assert.are.equal('Alpha', headers[1])
|
||||
assert.are.equal('Beta', headers[2])
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('priority_view', function()
|
||||
it('places all pending tasks before done tasks', function()
|
||||
local t1 = store.add({ description = 'Done A', category = 'Work' })
|
||||
store.add({ description = 'Pending B', category = 'Work' })
|
||||
store.update(t1.id, { status = 'done' })
|
||||
local _, meta = views.priority_view(store.active_tasks())
|
||||
local last_pending_row, first_done_row
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
if m.status == 'pending' then
|
||||
last_pending_row = i
|
||||
elseif m.status == 'done' and not first_done_row then
|
||||
first_done_row = i
|
||||
end
|
||||
end
|
||||
end
|
||||
assert.is_true(last_pending_row < first_done_row)
|
||||
end)
|
||||
|
||||
it('sorts pending tasks by priority desc within pending group', function()
|
||||
store.add({ description = 'Low', category = 'Work', priority = 0 })
|
||||
store.add({ description = 'High', category = 'Work', priority = 1 })
|
||||
local lines, meta = views.priority_view(store.active_tasks())
|
||||
local high_row, low_row
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
if lines[i]:find('High') then
|
||||
high_row = i
|
||||
elseif lines[i]:find('Low') then
|
||||
low_row = i
|
||||
end
|
||||
end
|
||||
end
|
||||
assert.is_true(high_row < low_row)
|
||||
end)
|
||||
|
||||
it('sorts pending tasks with due dates before those without', function()
|
||||
store.add({ description = 'No due', category = 'Work' })
|
||||
store.add({ description = 'Has due', category = 'Work', due = '2099-12-31' })
|
||||
local lines, meta = views.priority_view(store.active_tasks())
|
||||
local due_row, nodue_row
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
if lines[i]:find('Has due') then
|
||||
due_row = i
|
||||
elseif lines[i]:find('No due') then
|
||||
nodue_row = i
|
||||
end
|
||||
end
|
||||
end
|
||||
assert.is_true(due_row < nodue_row)
|
||||
end)
|
||||
|
||||
it('sorts pending tasks with earlier due dates before later due dates', function()
|
||||
store.add({ description = 'Later', category = 'Work', due = '2099-12-31' })
|
||||
store.add({ description = 'Earlier', category = 'Work', due = '2050-01-01' })
|
||||
local lines, meta = views.priority_view(store.active_tasks())
|
||||
local earlier_row, later_row
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
if lines[i]:find('Earlier') then
|
||||
earlier_row = i
|
||||
elseif lines[i]:find('Later') then
|
||||
later_row = i
|
||||
end
|
||||
end
|
||||
end
|
||||
assert.is_true(earlier_row < later_row)
|
||||
end)
|
||||
|
||||
it('formats task lines as /ID/ description', function()
|
||||
store.add({ description = 'My task', category = 'Inbox' })
|
||||
local lines, _ = views.priority_view(store.active_tasks())
|
||||
assert.are.equal('/1/ My task', lines[1])
|
||||
end)
|
||||
|
||||
it('sets show_category=true for all task meta entries', function()
|
||||
store.add({ description = 'T1', category = 'Work' })
|
||||
store.add({ description = 'T2', category = 'Personal' })
|
||||
local _, meta = views.priority_view(store.active_tasks())
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
assert.is_true(m.show_category == true)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
it('sets meta.category correctly for each task', function()
|
||||
store.add({ description = 'Work task', category = 'Work' })
|
||||
store.add({ description = 'Home task', category = 'Home' })
|
||||
local lines, meta = views.priority_view(store.active_tasks())
|
||||
local categories = {}
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
if lines[i]:find('Work task') then
|
||||
categories['Work task'] = m.category
|
||||
elseif lines[i]:find('Home task') then
|
||||
categories['Home task'] = m.category
|
||||
end
|
||||
end
|
||||
end
|
||||
assert.are.equal('Work', categories['Work task'])
|
||||
assert.are.equal('Home', categories['Home task'])
|
||||
end)
|
||||
|
||||
it('marks overdue pending tasks with meta.overdue=true', function()
|
||||
local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
|
||||
local t = store.add({ description = 'Overdue', category = 'Inbox', due = yesterday })
|
||||
local _, meta = views.priority_view(store.active_tasks())
|
||||
local task_meta
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.id == t.id then
|
||||
task_meta = m
|
||||
end
|
||||
end
|
||||
assert.is_true(task_meta.overdue == true)
|
||||
end)
|
||||
|
||||
it('does not mark future pending tasks as overdue', function()
|
||||
local tomorrow = os.date('%Y-%m-%d', os.time() + 86400)
|
||||
local t = store.add({ description = 'Future', category = 'Inbox', due = tomorrow })
|
||||
local _, meta = views.priority_view(store.active_tasks())
|
||||
local task_meta
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.id == t.id then
|
||||
task_meta = m
|
||||
end
|
||||
end
|
||||
assert.is_falsy(task_meta.overdue)
|
||||
end)
|
||||
|
||||
it('does not mark done tasks with overdue due dates as overdue', function()
|
||||
local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
|
||||
local t = store.add({ description = 'Done late', category = 'Inbox', due = yesterday })
|
||||
store.update(t.id, { status = 'done' })
|
||||
local _, meta = views.priority_view(store.active_tasks())
|
||||
local task_meta
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.id == t.id then
|
||||
task_meta = m
|
||||
end
|
||||
end
|
||||
assert.is_falsy(task_meta.overdue)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
Loading…
Add table
Add a link
Reference in a new issue