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
- 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*

View file

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

View file

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

View file

@ -63,6 +63,7 @@ function M.get()
return _resolved
end
---@return nil
function M.reset()
_resolved = nil
end

View file

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

View file

@ -1,5 +1,6 @@
local M = {}
---@return nil
function M.check()
vim.health.start('pending.nvim')

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
})