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.
This commit is contained in:
Barrett Ruth 2026-02-25 20:06:11 -05:00
parent c69afacc87
commit ee2d125846
11 changed files with 369 additions and 118 deletions

View file

@ -35,7 +35,7 @@ Features: ~
names, month names, ordinals, and more names, month names, ordinals, and more
- Recurring tasks with automatic next-date spawning on completion - 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
@ -242,7 +242,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*

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)
@ -79,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
@ -205,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
@ -249,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'

View file

@ -58,6 +58,18 @@ local function date_completions()
'soq', 'soq',
'soy', 'soy',
'later', 'later',
'today@08:00',
'today@09:00',
'today@10:00',
'today@12:00',
'today@14:00',
'today@17:00',
'tomorrow@08:00',
'tomorrow@09:00',
'tomorrow@10:00',
'tomorrow@12:00',
'tomorrow@14:00',
'tomorrow@17:00',
} }
end end

View file

@ -63,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

@ -65,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()

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')

View file

@ -1,4 +1,5 @@
local buffer = require('pending.buffer') local buffer = require('pending.buffer')
local config = require('pending.config')
local diff = require('pending.diff') local diff = require('pending.diff')
local parse = require('pending.parse') local parse = require('pending.parse')
local store = require('pending.store') local store = require('pending.store')
@ -6,8 +7,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 +18,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,6 +49,7 @@ 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 cfg = require('pending.config').get()
local km = cfg.keymaps local km = cfg.keymaps
@ -91,28 +92,33 @@ function M._setup_buf_mappings(bufnr)
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
@ -137,9 +143,7 @@ function M.toggle_complete()
if task.recur and task.due then if task.recur and task.due then
local recur = require('pending.recur') local recur = require('pending.recur')
local mode = task.recur_mode or 'scheduled' local mode = task.recur_mode or 'scheduled'
local base = mode == 'completion' and os.date('%Y-%m-%d') --[[@as string]] local next_date = recur.next_due(task.due, task.recur, mode)
or task.due
local next_date = recur.next_due(base, task.recur, mode)
store.add({ store.add({
description = task.description, description = task.description,
category = task.category, category = task.category,
@ -161,6 +165,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
@ -191,6 +196,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
@ -205,7 +211,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 (YYYY-MM-DD[Thh:mm]): ' }, function(input)
if not input then if not input then
return return
end end
@ -214,8 +220,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
@ -226,6 +235,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)
@ -252,6 +262,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
@ -262,6 +273,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)
@ -298,8 +310,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
@ -307,9 +357,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,
@ -321,8 +371,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 .. ']'
@ -342,6 +392,7 @@ function M.due()
end 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,6 +24,28 @@ 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 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'
@ -65,81 +87,138 @@ 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 ---@param text string
---@return string|nil ---@return string|nil
function M.resolve_date(text) function M.resolve_date(text)
local lower = text:lower() local date_input, time_suffix = text:match('^(.+)@(%d%d:%d%d)$')
if time_suffix then
if not is_valid_time(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]] local today = os.date('*t') --[[@as osdate]]
if lower == 'today' or lower == 'eod' then if lower == 'today' or lower == 'eod' then
return today_str(today) return append_time(today_str(today), time_suffix)
end end
if lower == 'yesterday' then if lower == 'yesterday' then
return os.date( return append_time(
'%Y-%m-%d', os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day - 1 })) --[[@as string]],
os.time({ year = today.year, month = today.month, day = today.day - 1 }) time_suffix
) --[[@as string]] )
end end
if lower == 'tomorrow' then if lower == 'tomorrow' then
return os.date( return append_time(
'%Y-%m-%d', os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) --[[@as string]],
os.time({ year = today.year, month = today.month, day = today.day + 1 }) time_suffix
) --[[@as string]] )
end end
if lower == 'sow' then if lower == 'sow' then
local delta = -((today.wday - 2) % 7) local delta = -((today.wday - 2) % 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
if lower == 'eow' then if lower == 'eow' then
local delta = (1 - today.wday) % 7 local delta = (1 - today.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
if lower == 'som' then if lower == 'som' then
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = 1 })) --[[@as string]] return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = 1 })) --[[@as string]],
time_suffix
)
end end
if lower == 'eom' then if lower == 'eom' then
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 })) --[[@as string]] return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 })) --[[@as string]],
time_suffix
)
end end
if lower == 'soq' then if lower == 'soq' then
local q = math.ceil(today.month / 3) local q = math.ceil(today.month / 3)
local first_month = (q - 1) * 3 + 1 local first_month = (q - 1) * 3 + 1
return os.date('%Y-%m-%d', os.time({ year = today.year, month = first_month, day = 1 })) --[[@as string]] return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = first_month, day = 1 })) --[[@as string]],
time_suffix
)
end end
if lower == 'eoq' then if lower == 'eoq' then
local q = math.ceil(today.month / 3) local q = math.ceil(today.month / 3)
local last_month = q * 3 local last_month = q * 3
return os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 })) --[[@as string]] return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 })) --[[@as string]],
time_suffix
)
end end
if lower == 'soy' then if lower == 'soy' then
return os.date('%Y-%m-%d', os.time({ year = today.year, month = 1, day = 1 })) --[[@as string]] return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = 1, day = 1 })) --[[@as string]],
time_suffix
)
end end
if lower == 'eoy' then if lower == 'eoy' then
return os.date('%Y-%m-%d', os.time({ year = today.year, month = 12, day = 31 })) --[[@as string]] return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = 12, day = 31 })) --[[@as string]],
time_suffix
)
end end
if lower == 'later' or lower == 'someday' then if lower == 'later' or lower == 'someday' then
return config.get().someday_date 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,
@ -148,12 +227,15 @@ function M.resolve_date(text)
tonumber(n) --[[@as integer]] tonumber(n) --[[@as integer]]
), ),
}) })
) --[[@as string]] ) --[[@as string]],
time_suffix
)
end end
n = lower:match('^%+(%d+)w$') n = lower:match('^%+(%d+)w$')
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,
@ -162,12 +244,15 @@ function M.resolve_date(text)
tonumber(n) --[[@as integer]] tonumber(n) --[[@as integer]]
) * 7, ) * 7,
}) })
) --[[@as string]] ) --[[@as string]],
time_suffix
)
end end
n = lower:match('^%+(%d+)m$') n = lower:match('^%+(%d+)m$')
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,
@ -176,12 +261,15 @@ function M.resolve_date(text)
), ),
day = today.day, day = today.day,
}) })
) --[[@as string]] ) --[[@as string]],
time_suffix
)
end end
n = lower:match('^%-(%d+)d$') 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,
@ -190,12 +278,15 @@ function M.resolve_date(text)
tonumber(n) --[[@as integer]] tonumber(n) --[[@as integer]]
), ),
}) })
) --[[@as string]] ) --[[@as string]],
time_suffix
)
end end
n = lower:match('^%-(%d+)w$') n = lower:match('^%-(%d+)w$')
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,
@ -204,7 +295,9 @@ function M.resolve_date(text)
tonumber(n) --[[@as integer]] tonumber(n) --[[@as integer]]
) * 7, ) * 7,
}) })
) --[[@as string]] ) --[[@as string]],
time_suffix
)
end end
local ord = lower:match('^(%d+)[snrt][tdh]$') local ord = lower:match('^(%d+)[snrt][tdh]$')
@ -222,7 +315,7 @@ function M.resolve_date(text)
local t = os.time({ year = y, month = m, day = day_num }) local t = os.time({ year = y, month = m, day = day_num })
local check = os.date('*t', t) --[[@as osdate]] local check = os.date('*t', t) --[[@as osdate]]
if check.day == day_num then if check.day == day_num then
return os.date('%Y-%m-%d', t) --[[@as string]] return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
end end
m = m + 1 m = m + 1
if m > 12 then if m > 12 then
@ -232,7 +325,7 @@ function M.resolve_date(text)
t = os.time({ year = y, month = m, day = day_num }) t = os.time({ year = y, month = m, day = day_num })
check = os.date('*t', t) --[[@as osdate]] check = os.date('*t', t) --[[@as osdate]]
if check.day == day_num then if check.day == day_num then
return os.date('%Y-%m-%d', t) --[[@as string]] return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
end end
return nil return nil
end end
@ -244,17 +337,23 @@ function M.resolve_date(text)
if today.month >= target_month then if today.month >= target_month then
y = y + 1 y = y + 1
end end
return os.date('%Y-%m-%d', os.time({ year = y, month = target_month, day = 1 })) --[[@as string]] 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
@ -273,7 +372,7 @@ function M.body(text)
local i = #tokens local i = #tokens
local dk = date_key() local dk = date_key()
local rk = recur_key() local rk = recur_key()
local date_pattern_strict = '^' .. 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[T%d:]*)$'
local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$' local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$'
local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$' local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$'
@ -284,7 +383,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

View file

@ -80,20 +80,33 @@ function M.validate(spec)
return M.parse(spec) ~= nil return M.parse(spec) ~= nil
end 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 base_date string
---@param freq string ---@param freq string
---@param interval integer ---@param interval integer
---@return string ---@return string
local function advance_date(base_date, freq, interval) local function advance_date(base_date, freq, interval)
local y, m, d = base_date:match('^(%d+)-(%d+)-(%d+)$') 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 yn = tonumber(y) --[[@as integer]]
local mn = tonumber(m) --[[@as integer]] local mn = tonumber(m) --[[@as integer]]
local dn = tonumber(d) --[[@as integer]] local dn = tonumber(d) --[[@as integer]]
local result
if freq == 'daily' then if freq == 'daily' then
return os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval })) --[[@as string]] result = os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval })) --[[@as string]]
elseif freq == 'weekly' then elseif freq == 'weekly' then
return os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval * 7 })) --[[@as string]] result = os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval * 7 })) --[[@as string]]
elseif freq == 'monthly' then elseif freq == 'monthly' then
local new_m = mn + interval local new_m = mn + interval
local new_y = yn local new_y = yn
@ -103,16 +116,22 @@ local function advance_date(base_date, freq, interval)
end end
local last_day = os.date('*t', os.time({ year = new_y, month = new_m + 1, day = 0 })) --[[@as osdate]] 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]]) local clamped_d = math.min(dn, last_day.day --[[@as integer]])
return os.date('%Y-%m-%d', os.time({ year = new_y, month = new_m, day = clamped_d })) --[[@as string]] result = os.date('%Y-%m-%d', os.time({ year = new_y, month = new_m, day = clamped_d })) --[[@as string]]
elseif freq == 'yearly' then elseif freq == 'yearly' then
local new_y = yn + interval local new_y = yn + interval
local last_day = os.date('*t', os.time({ year = new_y, month = mn + 1, day = 0 })) --[[@as osdate]] 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]]) local clamped_d = math.min(dn, last_day.day --[[@as integer]])
return os.date('%Y-%m-%d', os.time({ year = new_y, month = mn, day = clamped_d })) --[[@as string]] result = os.date('%Y-%m-%d', os.time({ year = new_y, month = mn, day = clamped_d })) --[[@as string]]
end else
return base_date return base_date
end end
if time_part then
return result .. 'T' .. time_part
end
return result
end
---@param base_date string ---@param base_date string
---@param spec string ---@param spec string
---@param mode 'scheduled'|'completion' ---@param mode 'scheduled'|'completion'
@ -124,13 +143,16 @@ function M.next_due(base_date, spec, mode)
end end
local today = os.date('%Y-%m-%d') --[[@as string]] local today = os.date('%Y-%m-%d') --[[@as string]]
local _, time_part = split_datetime(base_date)
if mode == 'completion' then if mode == 'completion' then
return advance_date(today, parsed.freq, parsed.interval) local base = time_part and (today .. 'T' .. time_part) or today
return advance_date(base, parsed.freq, parsed.interval)
end end
local next_date = advance_date(base_date, parsed.freq, parsed.interval) local next_date = advance_date(base_date, parsed.freq, parsed.interval)
while next_date <= today do 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) next_date = advance_date(next_date, parsed.freq, parsed.interval)
end end
return next_date return next_date

View file

@ -19,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 = {}
@ -34,6 +35,7 @@ local function empty_data()
version = SUPPORTED_VERSION, version = SUPPORTED_VERSION,
next_id = 1, next_id = 1,
tasks = {}, tasks = {},
undo = {},
} }
end end
@ -165,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
@ -182,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')
@ -300,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
@ -325,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

@ -21,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
@ -30,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[]
@ -74,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 = {}
@ -149,7 +174,7 @@ 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, recur = task.recur,
}) })
end end
@ -162,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 = {}
@ -200,7 +224,7 @@ 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, recur = task.recur,
}) })