diff --git a/doc/pending.txt b/doc/pending.txt index 66882b9..29b7e38 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 @@ -242,7 +242,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* diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 553be7d..932c414 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 task_winid and vim.api.nvim_win_is_valid(task_winid) then vim.api.nvim_win_close(task_winid, false) @@ -79,6 +81,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 @@ -205,6 +208,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 @@ -249,6 +253,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..17fc8a2 100644 --- a/lua/pending/complete.lua +++ b/lua/pending/complete.lua @@ -58,6 +58,18 @@ local function date_completions() 'soq', 'soy', '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 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..9dcdc45 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -1,4 +1,5 @@ local buffer = require('pending.buffer') +local config = require('pending.config') local diff = require('pending.diff') local parse = require('pending.parse') local store = require('pending.store') @@ -6,8 +7,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 +18,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 +49,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 +92,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 +143,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 +165,7 @@ function M.toggle_complete() end end +---@return nil function M.toggle_priority() local bufnr = buffer.bufnr() if not bufnr then @@ -191,6 +196,7 @@ function M.toggle_priority() end end +---@return nil function M.prompt_date() local bufnr = buffer.bufnr() if not bufnr then @@ -205,7 +211,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 (YYYY-MM-DD[Thh:mm]): ' }, function(input) if not input then return end @@ -214,8 +220,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 +235,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 +262,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 +273,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 +310,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 +357,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 +371,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 +392,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..73de18f 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -24,6 +24,28 @@ 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 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 +87,217 @@ 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('^(.+)@(%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]] 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 +315,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 +325,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 +337,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 +372,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 +383,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, })