I saw the post on recurring tasks on a single page (Recurring Tasks) and it made me wonder if I could have Claude Code create a more flexible option. So with a hat tip to Steven, here is a different take.
This version:
- Allows for widgets but doesn't require them.
- It allows for recurring tasks on any page.
- When you complete the task, it automatically creates a new one.
- Support for daily, weekly, monthly, yearly, and every N days
- Slash commands to easily and quickly add recurring tasks
tags: meta
Recurring Task System
A Space Lua library for recurring tasks. Tasks live wherever you write them; use isRecurringTaskDue(task) in any query to filter for what's due today.
Creating Recurring Tasks
Add [repeat: <frequency>] to any task. Optionally add [StartDate: YYYY-MM-DD] to anchor the recurrence.
- [ ] Review financials [repeat: monthly][StartDate: 2026-02-14]
- [ ] Daily standup [repeat: daily]
- [ ] Annual filing [repeat: yearly][StartDate: 2026-03-01]
- [ ] Quarterly review [repeat: quarterly][StartDate: 2026-01-15]
- [ ] Water plants [repeat: every 3 days][StartDate: 2026-02-14]
Or use slash commands: /taskDaily, /taskWeekly, /taskMonthly, /taskQuarterly, /taskYearly, /taskEveryNDays
Frequencies
| Frequency | Due when... |
|---|---|
daily |
Every day (after StartDate) |
weekly |
Today's weekday matches StartDate's weekday |
monthly |
Today's day-of-month matches StartDate's day |
quarterly |
Every 3 months on the matching day |
yearly |
Month and day both match StartDate |
every N days |
Every N days from StartDate (e.g. every 3 days) |
When DueDate is present, it is the source of truth -- the task is due when DueDate <= today. When DueDate is absent, frequency matching against StartDate is used as a fallback.
If no StartDate is set, the task is always considered due.
Querying on Any Page
Use isRecurringTaskDue(t) in any LIQ where clause. Note: repeat is a Lua reserved word, so use bracket syntax t["repeat"] in queries.
All due recurring tasks
${template.each(query[[
from t = index.tag "task"
where t["repeat"] and not t.done and isRecurringTaskDue(t)
order by t.page
]], templates.taskItem)}
On a client dashboard (filter by tag)
${template.each(query[[
from t = index.tag "task"
where t["repeat"] and not t.done and isRecurringTaskDue(t)
and table.includes(t.itags, "clientname")
]], templates.taskItem)}
With custom formatting
${template.each(query[[
from t = index.tag "task"
where t["repeat"] and not t.done and isRecurringTaskDue(t)
order by t.page
]], function(t)
return "- [ ] " .. (t.name or "") .. " | _" .. t["repeat"] .. "_ | [[" .. t.page .. "]]"
end)}
Widgets
Due today: ${widgets.recurringTasksDue()}
All recurring tasks overview: ${widgets.recurringTasksAll()}
If the self-querying widgets error, pass the query explicitly:
${widgets.recurringTasksDue(query[[
from t = index.tag "task"
where t["repeat"] and not t.done and isRecurringTaskDue(t)
order by t.page
]])}
Completion Behavior
When you check off a recurring task directly on its source page:
- The completed task gets
[CompletedDate: YYYY-MM-DD]appended (audit trail) - A new unchecked copy appears on the next line
- The new task's StartDate is set to the old DueDate (or today if no DueDate)
- The new task's DueDate is calculated from that StartDate + interval
This means completing a task early anchors the next occurrence to when it was originally due, not when you happened to check it off.
Caveats
- Date matching is exact (no rollover for missed days)
os.timemay have timezone quirks at date boundaries- Auto-renewal only fires when toggling tasks directly on the page, not from query/widget results
- Use
t["repeat"](bracket syntax) in queries becauserepeatis a Lua reserved word
Verification
After placing this file and running System: Reload (Ctrl+Alt+R):
- Add a test task on any page:
- [ ] Test recurring [repeat: daily][StartDate: 2026-02-14] - Check indexing works:
${query[[from t = index.tag "task" where t["repeat"] limit 5]]} - Test the widget:
${widgets.recurringTasksDue()} - Check off the test task and confirm a new copy appears below it
-- ===========================================================
-- Recurring Task System for SilverBullet v2
-- ===========================================================
-- ---- Date Helpers ----
local function parseDate(dateStr)
if not dateStr or type(dateStr) ~= "string" then return nil end
local y, m, d = dateStr:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)")
if not y then return nil end
return tonumber(y), tonumber(m), tonumber(d)
end
local function todayParts()
return tonumber(os.date("%Y")),
tonumber(os.date("%m")),
tonumber(os.date("%d")),
tonumber(os.date("%w")) -- 0=Sunday, 6=Saturday
end
local function toEpoch(y, m, d)
return os.time({ year = y, month = m, day = d, hour = 12 })
end
local function daysInMonth(y, m)
local days = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
if m == 2 and ((y % 4 == 0 and y % 100 ~= 0) or (y % 400 == 0)) then
return 29
end
return days[m]
end
local function clampDay(y, m, d)
local max = daysInMonth(y, m)
return d > max and max or d
end
local function parseEveryNDays(freq)
local n = freq:match("^every%s+(%d+)%s+days?$")
if n then return tonumber(n) end
return nil
end
-- Global so it is accessible from async slash command callbacks
function nextDueDate(freq, fromDate)
local y, m, d, baseTime
if fromDate then
local fy, fm, fd = parseDate(fromDate)
if fy then
y, m, d = fy, fm, fd
baseTime = toEpoch(y, m, d)
end
end
if not y then
y = tonumber(os.date("%Y"))
m = tonumber(os.date("%m"))
d = tonumber(os.date("%d"))
baseTime = os.time()
end
if freq == "daily" then
return os.date("%Y-%m-%d", baseTime + 86400)
elseif freq == "weekly" then
return os.date("%Y-%m-%d", baseTime + 7 * 86400)
elseif freq == "monthly" then
m = m + 1
if m > 12 then m = 1; y = y + 1 end
d = clampDay(y, m, d)
return string.format("%04d-%02d-%02d", y, m, d)
elseif freq == "quarterly" then
m = m + 3
if m > 12 then m = m - 12; y = y + 1 end
d = clampDay(y, m, d)
return string.format("%04d-%02d-%02d", y, m, d)
elseif freq == "yearly" then
y = y + 1
d = clampDay(y, m, d)
return string.format("%04d-%02d-%02d", y, m, d)
else
local n = parseEveryNDays(freq)
if n then
return os.date("%Y-%m-%d", baseTime + n * 86400)
end
end
return os.date("%Y-%m-%d")
end
-- ---- Core Function ----
-- Use in any LIQ where clause: isRecurringTaskDue(t)
function isRecurringTaskDue(task)
local freq = task["repeat"]
if not freq then return false end
freq = string.lower(tostring(freq))
local ty, tm, td, twday = todayParts()
local todayEpoch = toEpoch(ty, tm, td)
-- Guard: StartDate in the future -> not yet due
local sy, sm, sd = parseDate(task.StartDate)
if sy then
if toEpoch(sy, sm, sd) > todayEpoch then return false end
end
-- If DueDate exists, it is the source of truth
local dy, dm, dd = parseDate(task.DueDate)
if dy then
return toEpoch(dy, dm, dd) <= todayEpoch
end
-- Frequency matching (fallback when no DueDate)
if freq == "daily" then
return true
elseif freq == "weekly" then
if not sy then return true end
local startWday = tonumber(os.date("%w", toEpoch(sy, sm, sd)))
return twday == startWday
elseif freq == "monthly" then
if not sy then return true end
return td == sd
elseif freq == "quarterly" then
if not sy then return true end
return ((tm - sm) % 12) % 3 == 0 and td == sd
elseif freq == "yearly" then
if not sy then return true end
return tm == sm and td == sd
else
local n = parseEveryNDays(freq)
if n then
if not sy then return true end
local daysDiff = math.floor((todayEpoch - toEpoch(sy, sm, sd)) / 86400)
return daysDiff >= 0 and daysDiff % n == 0
end
end
return false -- unknown frequency
end
-- ---- Auto-Renew on Completion ----
event.listen { name = "task:stateChange", run = function(event)
local data = event.data or event
if not data then return end
-- Only handle task completion
if data.newState ~= "x" then return end
local from = data.from
local text = editor.getText()
if not text or text == "" then return end
-- Find the line containing 0-based position from
-- Convert to 1-based Lua string index
local p = from + 1
-- Scan backward to line start
local ls = 1
for i = p - 1, 1, -1 do
if text:sub(i, i) == "\n" then
ls = i + 1
break
end
end
-- Scan forward to line end
local le = #text
for i = p, #text do
if text:sub(i, i) == "\n" then
le = i - 1
break
end
end
local line = text:sub(ls, le)
-- Only process tasks with [repeat: ...]
local repeatVal = line:match("%[repeat:%s*([^%]]+)%]")
if not repeatVal then return end
-- Guard: make sure this line actually has a checked box
if not line:match("%[x%]") then return end
-- Guard: already has CompletedDate (already renewed)
if line:match("%[CompletedDate:") then return end
local today = os.date("%Y-%m-%d")
-- Extract old DueDate to anchor the next occurrence
local oldDueDate = line:match("%[DueDate:%s*([^%]]+)%]")
if oldDueDate then
oldDueDate = oldDueDate:gsub("%s+$", "")
end
-- New StartDate = old DueDate (or today if none)
local newStartDate = oldDueDate or today
-- Build completed line (add CompletedDate, trim trailing whitespace first)
local completedLine = line:gsub("%s+$", "") .. " [CompletedDate: " .. today .. "]"
-- Build new unchecked copy
local newLine = line:gsub("%[x%]", "[ ]", 1)
-- Update StartDate to newStartDate (anchored to old DueDate)
if newLine:match("%[StartDate:") then
newLine = newLine:gsub("%[StartDate:%s*[^%]]*%]", "[StartDate: " .. newStartDate .. "]")
end
-- Calculate next DueDate from the old DueDate (not from today)
local repeatFreq = string.lower(tostring(repeatVal)):gsub("%s+$", "")
local nextDue = nextDueDate(repeatFreq, oldDueDate)
if newLine:match("%[DueDate:") then
newLine = newLine:gsub("%[DueDate:%s*[^%]]*%]", "[DueDate: " .. nextDue .. "]")
else
newLine = newLine:gsub("%[repeat:", "[DueDate: " .. nextDue .. "][repeat:")
end
-- Remove any CompletedDate from the new copy
newLine = newLine:gsub("%s*%[CompletedDate:[^%]]*%]", "")
-- Replace original line and insert new copy below it
-- ls is 1-based line start; 0-based = ls - 1
-- le is 1-based line end (inclusive); 0-based exclusive = le
editor.replaceRange(ls - 1, le, completedLine .. "\n" .. newLine)
editor.flashNotification("Recurring task renewed")
end }
-- ---- Widgets ----
function widgets.recurringTasksDue(tasks)
if not tasks then
tasks = query[[
from t = index.tag "task"
where t["repeat"] and not t.done and isRecurringTaskDue(t)
order by t.page
]]
end
if not tasks or #tasks == 0 then
return widget.new { display = "block", markdown = "*No recurring tasks due today.*" }
end
local md = { "**Recurring Tasks Due Today**", "" }
for _, t in ipairs(tasks) do
local name = t.name or ""
local freq = t["repeat"] or ""
local page = t.page or ""
table.insert(md, "- [ ] " .. name .. " | _" .. freq .. "_ | [[" .. page .. "]]")
end
return widget.new { display = "block", markdown = table.concat(md, "\n") }
end
function widgets.recurringTasksAll(tasks)
if not tasks then
tasks = query[[
from t = index.tag "task"
where t["repeat"]
order by t["repeat"], t.page
]]
end
if not tasks or #tasks == 0 then
return widget.new { display = "block", markdown = "*No recurring tasks found.*" }
end
local md = {
"**All Recurring Tasks**",
"",
"| Status | Task | Frequency | Page | Start Date |",
"| --- | --- | --- | --- | --- |"
}
for _, t in ipairs(tasks) do
local status = t.done and "done" or "pending"
local name = t.name or ""
local freq = t["repeat"] or ""
local page = t.page or ""
local start = t.StartDate or "--"
table.insert(md, "| " .. status .. " | " .. name .. " | " .. freq .. " | [[" .. page .. "]] | " .. start .. " |")
end
return widget.new { display = "block", markdown = table.concat(md, "\n") }
end
-- ---- Slash Commands ----
local frequencies = { "daily", "weekly", "monthly", "quarterly", "yearly" }
for _, freq in ipairs(frequencies) do
local cmdName = "task" .. freq:sub(1, 1):upper() .. freq:sub(2)
slashCommand.define({
name = cmdName,
run = function()
local today = os.date("%Y-%m-%d")
local due = nextDueDate(freq)
editor.insertAtCursor("- [ ] [repeat: " .. freq .. "][StartDate: " .. today .. "][DueDate: " .. due .. "] ")
end
})
end
slashCommand.define({
name = "taskEveryNDays",
run = function()
local n = editor.prompt("How many days between occurrences?")
if not n or n == "" then return end
n = tonumber(n)
if not n or n < 1 then
editor.flashNotification("Please enter a valid number")
return
end
local today = os.date("%Y-%m-%d")
local freq = "every " .. n .. " days"
local due = nextDueDate(freq)
editor.insertAtCursor("- [ ] [repeat: " .. freq .. "][StartDate: " .. today .. "][DueDate: " .. due .. "] ")
end
})


