pending.nvim/lua/pending/recur.lua
Barrett Ruth c2310f2659 feat(recur): add recurrence parsing and next-date computation
Problem: the plugin has no concept of recurring tasks, which is
needed for habits and repeating deadlines.

Solution: add recur.lua with parse(), validate(), next_due(),
to_rrule(), and shorthand_list(). Supports named shorthands (daily,
weekdays, weekly, etc.), interval notation (Nd, Nw, Nm, Ny), raw
RRULE passthrough, and ! prefix for completion-based mode. Includes
day-clamping for month/year advancement.
2026-02-25 13:03:19 -05:00

160 lines
4.5 KiB
Lua

---@class pending.RecurSpec
---@field freq 'daily'|'weekly'|'monthly'|'yearly'
---@field interval integer
---@field byday? string[]
---@field from_completion boolean
---@class pending.recur
local M = {}
---@type table<string, pending.RecurSpec>
local named = {
daily = { freq = 'daily', interval = 1, from_completion = false },
weekdays = { freq = 'weekly', interval = 1, byday = { 'MO', 'TU', 'WE', 'TH', 'FR' }, from_completion = false },
weekly = { freq = 'weekly', interval = 1, from_completion = false },
biweekly = { freq = 'weekly', interval = 2, from_completion = false },
monthly = { freq = 'monthly', interval = 1, from_completion = false },
quarterly = { freq = 'monthly', interval = 3, from_completion = false },
yearly = { freq = 'yearly', interval = 1, from_completion = false },
annual = { freq = 'yearly', interval = 1, from_completion = false },
}
---@param spec string
---@return pending.RecurSpec?
function M.parse(spec)
local from_completion = false
local s = spec
if s:sub(1, 1) == '!' then
from_completion = true
s = s:sub(2)
end
local lower = s:lower()
local base = named[lower]
if base then
return {
freq = base.freq,
interval = base.interval,
byday = base.byday,
from_completion = from_completion,
}
end
local n, unit = lower:match('^(%d+)([dwmy])$')
if n then
local num = tonumber(n) --[[@as integer]]
if num < 1 then
return nil
end
local freq_map = { d = 'daily', w = 'weekly', m = 'monthly', y = 'yearly' }
return {
freq = freq_map[unit],
interval = num,
from_completion = from_completion,
}
end
if s:match('^FREQ=') then
return {
freq = 'daily',
interval = 1,
from_completion = from_completion,
_raw = s,
}
end
return nil
end
---@param spec string
---@return boolean
function M.validate(spec)
return M.parse(spec) ~= 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 yn = tonumber(y) --[[@as integer]]
local mn = tonumber(m) --[[@as integer]]
local dn = tonumber(d) --[[@as integer]]
if freq == 'daily' then
return 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]]
elseif freq == 'monthly' then
local new_m = mn + interval
local new_y = yn
while new_m > 12 do
new_m = new_m - 12
new_y = new_y + 1
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)
return 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)
return os.date('%Y-%m-%d', os.time({ year = new_y, month = mn, day = clamped_d })) --[[@as string]]
end
return base_date
end
---@param base_date string
---@param spec string
---@param mode 'scheduled'|'completion'
---@return string
function M.next_due(base_date, spec, mode)
local parsed = M.parse(spec)
if not parsed then
return base_date
end
local today = os.date('%Y-%m-%d') --[[@as string]]
if mode == 'completion' then
return advance_date(today, parsed.freq, parsed.interval)
end
local next_date = advance_date(base_date, parsed.freq, parsed.interval)
while next_date <= today do
next_date = advance_date(next_date, parsed.freq, parsed.interval)
end
return next_date
end
---@param spec string
---@return string
function M.to_rrule(spec)
local parsed = M.parse(spec)
if not parsed then
return ''
end
if parsed._raw then
return 'RRULE:' .. parsed._raw
end
local parts = { 'FREQ=' .. parsed.freq:upper() }
if parsed.interval > 1 then
table.insert(parts, 'INTERVAL=' .. parsed.interval)
end
if parsed.byday then
table.insert(parts, 'BYDAY=' .. table.concat(parsed.byday, ','))
end
return 'RRULE:' .. table.concat(parts, ';')
end
---@return string[]
function M.shorthand_list()
return { 'daily', 'weekdays', 'weekly', 'biweekly', 'monthly', 'quarterly', 'yearly', 'annual' }
end
return M