diff --git a/doc/pending.txt b/doc/pending.txt index 66882b9..aad924c 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -35,7 +35,7 @@ Features: ~ names, month names, ordinals, and more - Recurring tasks with automatic next-date spawning on completion - 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` - Quickfix list of overdue/due-today tasks via `:Pending due` - Foldable category sections (`zc`/`zo`) in category view @@ -149,6 +149,23 @@ token, the `D` prompt, and `:Pending add`. `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* @@ -242,7 +259,7 @@ COMMANDS *pending-commands* :Pending undo Undo the last `:w` save, restoring the task store to its previous state. Equivalent to the `U` buffer-local key (see |pending-mappings|). Up to 20 - levels of undo are retained per session. + levels of undo are persisted across sessions. ============================================================================== MAPPINGS *pending-mappings* @@ -417,6 +434,19 @@ Fields: ~ |pending.GcalConfig|. Omit this field entirely to 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* diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 06a14ac..4738830 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -37,10 +37,12 @@ function M.current_view_name() return current_view end +---@return nil function M.clear_winid() task_winid = nil end +---@return nil function M.close() if not task_winid or not vim.api.nvim_win_is_valid(task_winid) then task_winid = nil @@ -86,6 +88,7 @@ local function setup_syntax(bufnr) end ---@param above boolean +---@return nil function M.open_line(above) local bufnr = task_bufnr if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then @@ -212,6 +215,7 @@ local function restore_folds(bufnr) end ---@param bufnr? integer +---@return nil function M.render(bufnr) bufnr = bufnr or task_bufnr if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then @@ -256,6 +260,7 @@ function M.render(bufnr) restore_folds(bufnr) end +---@return nil function M.toggle_view() if current_view == 'category' then current_view = 'priority' diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua index f83b6a4..79f338b 100644 --- a/lua/pending/complete.lua +++ b/lua/pending/complete.lua @@ -29,48 +29,75 @@ local function get_categories() return result end ----@return string[] +---@return { word: string, info: string }[] local function date_completions() return { - 'today', - 'tomorrow', - 'yesterday', - '+1d', - '+2d', - '+3d', - '+1w', - '+2w', - '+1m', - 'mon', - 'tue', - 'wed', - 'thu', - 'fri', - 'sat', - 'sun', - 'eod', - 'eow', - 'eom', - 'eoq', - 'eoy', - 'sow', - 'som', - 'soq', - 'soy', - 'later', + { 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 ----@return string[] +---@type table +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 - table.insert(result, s) + local desc = recur_descriptions[s] or s + table.insert(result, { word = s, info = desc }) end for _, s in ipairs(list) do - table.insert(result, '!' .. s) + local desc = recur_descriptions[s] or s + table.insert(result, { word = '!' .. s, info = desc .. ' (from completion date)' }) end return result end @@ -111,24 +138,29 @@ function M.omnifunc(findstart, base) return -1 end - local candidates = {} + local matches = {} local source = _complete_source or '' local dk = date_key() local rk = recur_key() if source == dk then - candidates = date_completions() + 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 - candidates = get_categories() + 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 - candidates = recur_completions() - end - - local matches = {} - for _, c in ipairs(candidates) do - if base == '' or c:sub(1, #base) == base then - table.insert(matches, { word = c, menu = '[' .. source .. ']' }) + 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 diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 3318b3d..ec89cb2 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -63,6 +63,7 @@ function M.get() return _resolved end +---@return nil function M.reset() _resolved = nil end diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index bec3baa..daab788 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -65,6 +65,7 @@ function M.parse_buffer(lines) end ---@param lines string[] +---@return nil function M.apply(lines) local parsed = M.parse_buffer(lines) local now = timestamp() diff --git a/lua/pending/health.lua b/lua/pending/health.lua index 78311d2..cc285e0 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -1,5 +1,6 @@ local M = {} +---@return nil function M.check() vim.health.start('pending.nvim') diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 216b8b3..631c0e3 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -6,8 +6,6 @@ local store = require('pending.store') ---@class pending.init local M = {} ----@type pending.Task[][] -local _undo_states = {} local UNDO_MAX = 20 ---@return integer bufnr @@ -19,6 +17,7 @@ function M.open() end ---@param bufnr integer +---@return nil function M._setup_autocmds(bufnr) local group = vim.api.nvim_create_augroup('PendingBuffer', { clear = true }) vim.api.nvim_create_autocmd('BufWriteCmd', { @@ -49,6 +48,7 @@ function M._setup_autocmds(bufnr) end ---@param bufnr integer +---@return nil function M._setup_buf_mappings(bufnr) local cfg = require('pending.config').get() local km = cfg.keymaps @@ -91,28 +91,33 @@ function M._setup_buf_mappings(bufnr) end ---@param bufnr integer +---@return nil function M._on_write(bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local snapshot = store.snapshot() - table.insert(_undo_states, snapshot) - if #_undo_states > UNDO_MAX then - table.remove(_undo_states, 1) + local stack = store.undo_stack() + table.insert(stack, snapshot) + if #stack > UNDO_MAX then + table.remove(stack, 1) end diff.apply(lines) buffer.render(bufnr) end +---@return nil 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) return end - local state = table.remove(_undo_states) + local state = table.remove(stack) store.replace_tasks(state) store.save() buffer.render(buffer.bufnr()) end +---@return nil function M.toggle_complete() local bufnr = buffer.bufnr() if not bufnr then @@ -137,9 +142,7 @@ function M.toggle_complete() if task.recur and task.due then local recur = require('pending.recur') local mode = task.recur_mode or 'scheduled' - local base = mode == 'completion' and os.date('%Y-%m-%d') --[[@as string]] - or task.due - local next_date = recur.next_due(base, task.recur, mode) + local next_date = recur.next_due(task.due, task.recur, mode) store.add({ description = task.description, category = task.category, @@ -161,6 +164,7 @@ function M.toggle_complete() end end +---@return nil function M.toggle_priority() local bufnr = buffer.bufnr() if not bufnr then @@ -191,6 +195,7 @@ function M.toggle_priority() end end +---@return nil function M.prompt_date() local bufnr = buffer.bufnr() if not bufnr then @@ -205,7 +210,7 @@ function M.prompt_date() if not id then return end - vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input) + vim.ui.input({ prompt = 'Due date (today, +3d, fri@2pm, etc.): ' }, function(input) if not input then return end @@ -214,8 +219,11 @@ function M.prompt_date() local resolved = parse.resolve_date(due) if resolved then due = resolved - elseif not due:match('^%d%d%d%d%-%d%d%-%d%d$') then - vim.notify('Invalid date format. Use YYYY-MM-DD.', vim.log.levels.ERROR) + elseif + not due:match('^%d%d%d%d%-%d%d%-%d%d$') + and not due:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$') + then + vim.notify('Invalid date format. Use YYYY-MM-DD or YYYY-MM-DDThh:mm.', vim.log.levels.ERROR) return end end @@ -226,6 +234,7 @@ function M.prompt_date() end ---@param text string +---@return nil function M.add(text) if not text or text == '' then vim.notify('Usage: :Pending add ', vim.log.levels.ERROR) @@ -252,6 +261,7 @@ function M.add(text) vim.notify('Pending added: ' .. description) end +---@return nil function M.sync() local ok, gcal = pcall(require, 'pending.sync.gcal') if not ok then @@ -262,6 +272,7 @@ function M.sync() end ---@param days? integer +---@return nil function M.archive(days) days = days or 30 local cutoff = os.time() - (days * 86400) @@ -298,8 +309,46 @@ function M.archive(days) 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 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 is_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr) local meta = is_valid and buffer.meta() or nil @@ -307,9 +356,9 @@ function M.due() if meta and bufnr then for lnum, m in ipairs(meta) do - if m.type == 'task' and m.raw_due and m.status ~= 'done' and m.raw_due <= today then + 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 label = m.raw_due < today and '[OVERDUE] ' or '[DUE] ' + local label = is_overdue(m.raw_due) and '[OVERDUE] ' or '[DUE] ' table.insert(qf_items, { bufnr = bufnr, lnum = lnum, @@ -321,8 +370,8 @@ function M.due() else store.load() for _, task in ipairs(store.active_tasks()) do - if task.status == 'pending' and task.due and task.due <= today then - local label = task.due < today and '[OVERDUE] ' or '[DUE] ' + if task.status == 'pending' and task.due and is_due_or_overdue(task.due) then + local label = is_overdue(task.due) and '[OVERDUE] ' or '[DUE] ' local text = label .. task.description if task.category then text = text .. ' [' .. task.category .. ']' @@ -342,6 +391,7 @@ function M.due() end ---@param args string +---@return nil function M.command(args) if not args or args == '' then M.open() diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 853fa2c..e234269 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -24,6 +24,82 @@ local function is_valid_date(s) return check.year == yn and check.month == mn and check.day == dn end +---@param s string +---@return boolean +local function is_valid_time(s) + local h, m = s:match('^(%d%d):(%d%d)$') + if not h then + return false + end + local hn = tonumber(h) --[[@as integer]] + local mn = tonumber(m) --[[@as integer]] + return hn >= 0 and hn <= 23 and mn >= 0 and mn <= 59 +end + +---@param s string +---@return string|nil +local function normalize_time(s) + local h, m, period + + h, m, period = s:match('^(%d+):(%d%d)([ap]m)$') + if not h then + h, period = s:match('^(%d+)([ap]m)$') + if h then + m = '00' + end + end + if not h then + h, m = s:match('^(%d%d):(%d%d)$') + end + if not h then + h, m = s:match('^(%d):(%d%d)$') + end + if not h then + h = s:match('^(%d+)$') + if h then + m = '00' + end + end + + if not h then + return nil + end + + local hn = tonumber(h) --[[@as integer]] + local mn = tonumber(m) --[[@as integer]] + + if period then + if hn < 1 or hn > 12 then + return nil + end + if period == 'am' then + hn = hn == 12 and 0 or hn + else + hn = hn == 12 and 12 or hn + 12 + end + else + if hn < 0 or hn > 23 then + return nil + end + end + + if mn < 0 or mn > 59 then + return nil + end + + return string.format('%02d:%02d', hn, mn) +end + +---@param s string +---@return boolean +local function is_valid_datetime(s) + local date_part, time_part = s:match('^(.+)T(.+)$') + if not date_part then + return is_valid_date(s) + end + return is_valid_date(date_part) and is_valid_time(time_part) +end + ---@return string local function date_key() return config.get().date_syntax or 'due' @@ -65,146 +141,218 @@ local function today_str(today) return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]] end +---@param date_part string +---@param time_suffix? string +---@return string +local function append_time(date_part, time_suffix) + if time_suffix then + return date_part .. 'T' .. time_suffix + end + return date_part +end + ---@param text string ---@return string|nil function M.resolve_date(text) - local lower = text:lower() + local date_input, time_suffix = text:match('^(.+)@(.+)$') + if time_suffix then + time_suffix = normalize_time(time_suffix) + if not time_suffix then + return nil + end + else + date_input = text + end + + local dt = date_input:match('^(%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d)$') + if dt then + local dp, tp = dt:match('^(.+)T(.+)$') + if is_valid_date(dp) and is_valid_time(tp) then + return dt + end + return nil + end + + if is_valid_date(date_input) then + return append_time(date_input, time_suffix) + end + + local lower = date_input:lower() local today = os.date('*t') --[[@as osdate]] if lower == 'today' or lower == 'eod' then - return today_str(today) + return append_time(today_str(today), time_suffix) end if lower == 'yesterday' then - return os.date( - '%Y-%m-%d', - os.time({ year = today.year, month = today.month, day = today.day - 1 }) - ) --[[@as string]] + return append_time( + os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day - 1 })) --[[@as string]], + time_suffix + ) end if lower == 'tomorrow' then - return os.date( - '%Y-%m-%d', - os.time({ year = today.year, month = today.month, day = today.day + 1 }) - ) --[[@as string]] + return append_time( + os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) --[[@as string]], + time_suffix + ) end if lower == 'sow' then local delta = -((today.wday - 2) % 7) - return os.date( - '%Y-%m-%d', - os.time({ year = today.year, month = today.month, day = today.day + delta }) - ) --[[@as string]] + return append_time( + os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + delta }) + ) --[[@as string]], + time_suffix + ) end if lower == 'eow' then local delta = (1 - today.wday) % 7 - return os.date( - '%Y-%m-%d', - os.time({ year = today.year, month = today.month, day = today.day + delta }) - ) --[[@as string]] + return append_time( + os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + delta }) + ) --[[@as string]], + time_suffix + ) end 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 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 if lower == 'soq' then local q = math.ceil(today.month / 3) 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 if lower == 'eoq' then local q = math.ceil(today.month / 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 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 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 if lower == 'later' or lower == 'someday' then - return config.get().someday_date + return append_time(config.get().someday_date, time_suffix) end local n = lower:match('^%+(%d+)d$') if n then - return os.date( - '%Y-%m-%d', - os.time({ - year = today.year, - month = today.month, - day = today.day + ( - tonumber(n) --[[@as integer]] - ), - }) - ) --[[@as string]] + return append_time( + os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month, + day = today.day + ( + tonumber(n) --[[@as integer]] + ), + }) + ) --[[@as string]], + time_suffix + ) end n = lower:match('^%+(%d+)w$') if n then - return os.date( - '%Y-%m-%d', - os.time({ - year = today.year, - month = today.month, - day = today.day + ( - tonumber(n) --[[@as integer]] - ) * 7, - }) - ) --[[@as string]] + return append_time( + os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month, + day = today.day + ( + tonumber(n) --[[@as integer]] + ) * 7, + }) + ) --[[@as string]], + time_suffix + ) end n = lower:match('^%+(%d+)m$') if n then - return os.date( - '%Y-%m-%d', - os.time({ - year = today.year, - month = today.month + ( - tonumber(n) --[[@as integer]] - ), - day = today.day, - }) - ) --[[@as string]] + 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 os.date( - '%Y-%m-%d', - os.time({ - year = today.year, - month = today.month, - day = today.day - ( - tonumber(n) --[[@as integer]] - ), - }) - ) --[[@as string]] + return append_time( + os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month, + day = today.day - ( + tonumber(n) --[[@as integer]] + ), + }) + ) --[[@as string]], + time_suffix + ) end n = lower:match('^%-(%d+)w$') if n then - return os.date( - '%Y-%m-%d', - os.time({ - year = today.year, - month = today.month, - day = today.day - ( - tonumber(n) --[[@as integer]] - ) * 7, - }) - ) --[[@as string]] + return append_time( + os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month, + day = today.day - ( + tonumber(n) --[[@as integer]] + ) * 7, + }) + ) --[[@as string]], + time_suffix + ) end local ord = lower:match('^(%d+)[snrt][tdh]$') @@ -222,7 +370,7 @@ function M.resolve_date(text) 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 os.date('%Y-%m-%d', t) --[[@as string]] + return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix) end m = m + 1 if m > 12 then @@ -232,7 +380,7 @@ function M.resolve_date(text) t = os.time({ year = y, month = m, day = day_num }) check = os.date('*t', t) --[[@as osdate]] 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 return nil end @@ -244,17 +392,23 @@ function M.resolve_date(text) if today.month >= target_month then y = y + 1 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 local target_wday = weekday_map[lower] if target_wday then local current_wday = today.wday local delta = (target_wday - current_wday) % 7 - return os.date( - '%Y-%m-%d', - os.time({ year = today.year, month = today.month, day = today.day + delta }) - ) --[[@as string]] + return append_time( + os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + delta }) + ) --[[@as string]], + time_suffix + ) end return nil @@ -273,7 +427,7 @@ function M.body(text) local i = #tokens local dk = date_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 rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$' @@ -284,7 +438,7 @@ function M.body(text) if metadata.due then break end - if not is_valid_date(due_val) then + if not is_valid_datetime(due_val) then break end metadata.due = due_val diff --git a/lua/pending/recur.lua b/lua/pending/recur.lua index c0a2091..9c647aa 100644 --- a/lua/pending/recur.lua +++ b/lua/pending/recur.lua @@ -80,20 +80,33 @@ 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 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 mn = tonumber(m) --[[@as integer]] local dn = tonumber(d) --[[@as integer]] + local result 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 - 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 local new_m = mn + interval local new_y = yn @@ -103,14 +116,20 @@ local function advance_date(base_date, freq, interval) 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]]) - 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 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]]) - 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]] + else + return base_date end - return base_date + + if time_part then + return result .. 'T' .. time_part + end + return result end ---@param base_date string @@ -124,13 +143,16 @@ function M.next_due(base_date, spec, mode) end local today = os.date('%Y-%m-%d') --[[@as string]] + local _, time_part = split_datetime(base_date) 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 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) end return next_date diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 4f3d2f1..c9e9b45 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -19,6 +19,7 @@ local config = require('pending.config') ---@field version integer ---@field next_id integer ---@field tasks pending.Task[] +---@field undo pending.Task[][] ---@class pending.store local M = {} @@ -34,6 +35,7 @@ local function empty_data() version = SUPPORTED_VERSION, next_id = 1, tasks = {}, + undo = {}, } end @@ -165,13 +167,24 @@ function M.load() version = decoded.version or SUPPORTED_VERSION, next_id = decoded.next_id or 1, tasks = {}, + undo = {}, } for _, t in ipairs(decoded.tasks or {}) do table.insert(_data.tasks, table_to_task(t)) 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 end +---@return nil function M.save() if not _data then return @@ -182,10 +195,18 @@ function M.save() version = _data.version, next_id = _data.next_id, tasks = {}, + undo = {}, } for _, task in ipairs(_data.tasks) do table.insert(out.tasks, task_to_table(task)) 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 tmp = path .. '.tmp' local f = io.open(tmp, 'w') @@ -300,6 +321,7 @@ function M.find_index(id) end ---@param tasks pending.Task[] +---@return nil function M.replace_tasks(tasks) M.data().tasks = tasks end @@ -325,11 +347,24 @@ function M.snapshot() return result 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 +---@return nil function M.set_next_id(id) M.data().next_id = id end +---@return nil function M.unload() _data = nil end diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 17a7a37..a9f56bf 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -21,7 +21,10 @@ local function format_due(due) if not due then return nil end - local y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') + local y, m, d, hh, mm = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d)$') + if not y then + y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') + end if not y then return due end @@ -30,7 +33,30 @@ local function format_due(due) month = tonumber(m) --[[@as integer]], day = tonumber(d) --[[@as integer]], }) - return os.date(config.get().date_format, t) --[[@as string]] + local formatted = os.date(config.get().date_format, t) --[[@as string]] + if hh then + formatted = formatted .. ' ' .. hh .. ':' .. mm + end + return formatted +end + +---@param 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 ---@param tasks pending.Task[] @@ -74,7 +100,6 @@ end ---@return string[] lines ---@return pending.LineMeta[] meta function M.category_view(tasks) - local today = os.date('%Y-%m-%d') --[[@as string]] local by_cat = {} local cat_order = {} local cat_seen = {} @@ -149,7 +174,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, + overdue = task.status == 'pending' and task.due ~= nil and is_overdue(task.due) or nil, recur = task.recur, }) end @@ -162,7 +187,6 @@ end ---@return string[] lines ---@return pending.LineMeta[] meta function M.priority_view(tasks) - local today = os.date('%Y-%m-%d') --[[@as string]] local pending = {} local done = {} @@ -200,7 +224,7 @@ function M.priority_view(tasks) raw_due = task.due, status = task.status, category = task.category, - overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil, + overdue = task.status == 'pending' and task.due ~= nil and is_overdue(task.due) or nil, show_category = true, recur = task.recur, }) diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index edeffcd..bc313b0 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -323,6 +323,73 @@ describe('parse', function() end) end) + describe('resolve_date with time suffix', function() + local today = os.date('*t') --[[@as osdate]] + local tomorrow_str = + os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) --[[@as string]] + + it('resolves bare hour to T09:00', function() + local result = parse.resolve_date('tomorrow@9') + assert.are.equal(tomorrow_str .. 'T09:00', result) + end) + + it('resolves bare military hour to T14:00', function() + local result = parse.resolve_date('tomorrow@14') + assert.are.equal(tomorrow_str .. 'T14:00', result) + end) + + it('resolves H:MM to T09:30', function() + local result = parse.resolve_date('tomorrow@9:30') + assert.are.equal(tomorrow_str .. 'T09:30', result) + end) + + it('resolves HH:MM (existing format) to T09:30', function() + local result = parse.resolve_date('tomorrow@09:30') + assert.are.equal(tomorrow_str .. 'T09:30', result) + end) + + it('resolves 2pm to T14:00', function() + local result = parse.resolve_date('tomorrow@2pm') + assert.are.equal(tomorrow_str .. 'T14:00', result) + end) + + it('resolves 9am to T09:00', function() + local result = parse.resolve_date('tomorrow@9am') + assert.are.equal(tomorrow_str .. 'T09:00', result) + end) + + it('resolves 9:30pm to T21:30', function() + local result = parse.resolve_date('tomorrow@9:30pm') + assert.are.equal(tomorrow_str .. 'T21:30', result) + end) + + it('resolves 12am to T00:00', function() + local result = parse.resolve_date('tomorrow@12am') + assert.are.equal(tomorrow_str .. 'T00:00', result) + end) + + it('resolves 12pm to T12:00', function() + local result = parse.resolve_date('tomorrow@12pm') + assert.are.equal(tomorrow_str .. 'T12:00', result) + end) + + it('rejects hour 24', function() + assert.is_nil(parse.resolve_date('tomorrow@24')) + end) + + it('rejects 13am', function() + assert.is_nil(parse.resolve_date('tomorrow@13am')) + end) + + it('rejects minute 60', function() + assert.is_nil(parse.resolve_date('tomorrow@9:60')) + end) + + it('rejects alphabetic garbage', function() + assert.is_nil(parse.resolve_date('tomorrow@abc')) + end) + end) + describe('command_add', function() it('parses simple text', function() local desc, meta = parse.command_add('Buy milk')