Another Take on Recurring Tasks

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:

  1. The completed task gets [CompletedDate: YYYY-MM-DD] appended (audit trail)
  2. A new unchecked copy appears on the next line
  3. The new task's StartDate is set to the old DueDate (or today if no DueDate)
  4. 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.time may 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 because repeat is a Lua reserved word

Verification

After placing this file and running System: Reload (Ctrl+Alt+R):

  1. Add a test task on any page: - [ ] Test recurring [repeat: daily][StartDate: 2026-02-14]
  2. Check indexing works: ${query[[from t = index.tag "task" where t["repeat"] limit 5]]}
  3. Test the widget: ${widgets.recurringTasksDue()}
  4. 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
})
4 Likes

Thank you much for this plug. The other one wasn't working very well for me. I had asked the author for more examples but haven't seen any yet.

I didn't need an audit trail of past tasks so I changed this line to remove the previous task after it was completed:

editor.replaceRange(ls - 1, le, "" .. newLine)

Other than that I just changed the sort on the widgets.recurringTasksDue to use t.name instead of t.page. Then I used ref = t.ref to display the specific Task reference for jumping to it.

    local ref = t.ref or ""
    table.insert(md, "- [ ] " .. name .. " | _" .. freq .. "_ | [[" .. ref .. "]]")

I also use Obsidian with its Templater plugin to create my task list for each day. So, while there is a history of tasks saved in Obsidian, I am just using one task list in SB that just changes Due Dates from day to day.

Thanks again for this version of a Task Manager plug.

1 Like

Thanks for the plugin.

I edited to the linkedTasks widget to include an item in the upcoming day

function widgets.linkedTasks(pageName)  
  pageName = pageName or editor.getCurrentPage()  
    
  local tasks = query[[  
    from t = index.tag "task"  
    where not t.done and (  
      string.find(t.name, "[[" .. pageName .. "]]", 1, true) or  
      (t.DueDate and string.find(pageName, t.DueDate, 1, true))  
    )  
    order by t.page
  ]]
    
  local md = ""  
  if #tasks > 0 then  
    md = "# Linked Tasks\n"  
       .. template.each(tasks, templates.taskItem)  
  else  
    md = ""  
  end  
  return widget.new {  
    markdown = md  
  }  
end

Would be amazing if the tasks could be added to the current note or the parent updated, but it looks to be a limitation of silverbullet itself (according to silverbulletmd/silverbullet | DeepWiki)

1 Like

I just wanted to share how I track daily tasks, most of which are exercises. First, I have a slightly modified Recurring Task widget that shows today’s tasks. This is followed by a copy of that widget that filters tasks starting with the word Pay, because those are recurring Bills. The source of these two queries are a RecurringTasks page.

Next are two queries using taskItem templates that filter on either an attribute for Calisthenics [cali:1 ] or for Isometrics [iso:1]. Since these are only on Monday, Wednesday and Friday, I copy these into my daily Journal page via slash commands when needed.

The recurring page is as below with three sections, Regular weekly tasks, Monthly Bill tasks and Daily Routine tasks that begin with a time (i.e. like a Day Planner).

For my daily Journal page, it always contains a place to record Walking times and duration. On M-W-F days it also has two task sections for Calisthenics and Isometrics. So on M-W-F there are many things to check off. Being almost 70 years old I am starting to focus on exercise much more.

This setup has been working well. I am also deep into Obsidian and previously used Logseq (until I found out its Plug Ins don’t work on Mobile). So I have some duplication of efforts. Perhaps this will give some of you ideas on more ways to use Silver Bullet.

1 Like