Tyler Teschon54 downloadsCreate and manage note-per-task records with RRULE recurrence, backed by a Bases task base.
An Obsidian plugin for a note-per-task system: capture a task through a form, pick a cadence from a builder instead of typing RFC 5545, and complete a task with the next due date computed for you.
It conforms to an existing schema rather than inventing one. A note is a task
because it carries type: task; a Bases file collects them from anywhere in
the vault.
A community plugin, not affiliated with or endorsed by Obsidian. "Bases" refers to Obsidian's own feature, which this plugin reads and writes but is not part of.
The pane's sections partition the open set — Later is whatever the other
sections did not catch, so nothing can be due-dated into invisibility. It is
collapsed by default so the pane opens on what is urgent.
Recurrence without typing RFC 5545. The preview shows the rule it will store,
what that means in English, and the next three dates it resolves to — here
30 September, 31 October, 30 November, because BYMONTHDAY=-1 means the last
day, not the 30th.

Capture on the left, edit on the right. Every field stays changeable after creation, and the asset field is a type-ahead over the notes in your vault rather than free text you can typo.
The same tasks in Obsidian's own Bases table. The plugin can create this base for you; it is optional, and the pane works without it.
No plugin in Obsidian's 7,000-plus community registry mentions RRULE. That
is the distinction: recurrence here is an RFC 5545 rule stored on the note —
FREQ=MONTHLY;INTERVAL=6;BYMONTHDAY=-1 — evaluated with the same library
calendar software uses, rather than a bespoke "every 6 months" string.
This one is for things that come back on a schedule — maintenance, chores, services — where the note is the record, a Bases file is the view, and the recurrence rule is a standard you can read somewhere else.
---
done: false # checkbox
due: 2026-12-31 # date
created: 2026-08-31 # date
priority: low # low | medium | high
category: vehicle # home | yard | errands | vehicle | health
last done: 2026-06-19 # date — latest completion only
frequency: FREQ=MONTHLY;INTERVAL=6;BYMONTHDAY=-1 # RRULE, or empty
type: task # identity — this is what puts it in the base
asset: "[[Family Car]]" # optional
---
frequency is the only thing separating the two kinds of task. Set means
recurring; empty means one-time.
| Command | What it does |
|---|---|
| Create task | Form for name, category, priority, due, repeat rule, asset |
| Complete task | Branches on frequency — see below |
| Edit task | Change due date, priority, category, repeat rule or asset |
| Edit repeat rule | Opens the RRULE builder on a task |
| Set asset | Attaches, changes, or clears the asset on an existing task |
| Create asset note | Creates a type: asset note and opens it — no task needed |
| Recompute due date from repeat rule | Rolls due forward from last done |
| Open task base | Opens the base file, creating one if there isn't one |
| Open task list | The sidebar view |
The task pane carries its own toolbar — New task, refresh, and a button that opens the base file, creating one if you don't have a base yet. Its filters are generated from the Excluded folders setting, so a base made this way agrees with the pane by construction. It sticks to the top so the primary action stays reachable once the list scrolls. Right-click any row for Edit / Complete / Open.
Its sections — Overdue, Today, This week, Needs attention, Later — partition
the open set: Later is defined as everything not caught by the others, not
as a date window, so no open task can be missing from the pane while the footer
counts it. Later is collapsed by default, with its count on the header.
Commands act on the active note when it is a task, and offer a picker when it is not.
A task's asset property links the note for the thing being serviced —
asset: "[[Lawn Mower]]".
A note is an asset because it carries type: asset, the same rule this
plugin uses for tasks. A vault may also collect the same notes by folder — an
Inventory base filtering on file.inFolder("Inventory"), say. The two agree
as long as every asset note lives in that folder, and diverge if one is moved
out; matching on the property is what keeps the picker working either way.
The asset field is a type-ahead: click it and every asset drops down, typing narrows the list. A name with no matching note is still accepted — capturing a chore for something not yet inventoried is normal — but the field says so plainly rather than writing a dangling link in silence.
The field states that rule and the + beside it acts on it. Where the list came from used to be answerable only by reading this file: an empty dropdown explained neither why it was empty nor what would fill it. The field now says what makes a note an asset, and the button creates one without leaving the form — the new note appears in the list straight away, and its name drops into the field you were filling in.
New asset notes go to the New asset folder setting, and carry exactly two properties:
---
type: asset
created: 2026-09-03
---
That is the whole contract. type: asset is what puts the note in the picker;
created matches what tasks carry and is a real date, so it sorts. Nothing
else is written, because a lawn mower is described with fields no task plugin
can guess — model numbers, warranty dates, where it lives — and those are yours
to add.
The folder is only consulted when writing one. Assets are still found anywhere in the vault by their property, so moving a note out of that folder does not stop it being an asset.
last done = today, due recomputed, done back to
false. A recurring task is never left at done: true; that is what makes
it recur.last done = today, done = true. The note stays on disk
and stays in the base. Nothing is ever deleted.done: true and retire a task that was
meant to keep recurring.Anything that is not a date — mileage, part numbers, what was actually done —
goes on a dated line under ## Service log in the body, never into last done.
The policy is skip the rest of the period you just did it in, then take the schedule's next occurrence.
| Rule | Completed | Next due |
|---|---|---|
FREQ=WEEKLY;BYDAY=MO |
Mon 2026-08-24 | 2026-08-31 |
FREQ=WEEKLY;BYDAY=SU |
Mon 2026-08-17 | 2026-08-30 |
FREQ=WEEKLY;INTERVAL=2;BYDAY=MO |
Mon 2026-08-24 | 2026-09-07 |
FREQ=MONTHLY;BYMONTHDAY=-1 |
2026-08-22 | 2026-09-30 |
FREQ=MONTHLY;INTERVAL=6;BYMONTHDAY=-1 |
2026-06-19 | 2026-12-31 |
FREQ=YEARLY |
2026-06-19 | 2027-06-19 |
Three cases, in order:
FREQ=YEARLY) — the day is implied by the
anchor, so the roll is one period later. A yearly task done 19 June comes
back the following 19 June, not on 1 January.FREQ=WEEKLY;BYDAY=MO,TH) — the
remaining occurrences this period are real, so take the strict next one.INTERVAL does the rest of the skipping itself.Case 3 is why this is not a plain rrule.after(today). Anchoring at the
completion date makes INTERVAL step from there, so an oil change on
2026-06-19 under FREQ=MONTHLY;INTERVAL=6;BYMONTHDAY=-1 would roll to
2026-06-30 — eleven days out instead of six months.
done: true, and
unreadable repeat rules, and the generated base carries a Sweep view of
finished one-time tasks - but all three only ever report..obsidian/types.json. The property-type registry is vault-wide;
a stray write there breaks date sorting on every note at once.Bases exposes no documented plugin API, so src/model/taskRepository.ts does
not query tasks/task base.base. It walks the vault and re-implements the
base's two filter clauses against the metadata cache:
type == "task" and !file.inFolder("Templates")
If the .base file's filters change, that file must change with them, or
the sidebar and the base will quietly disagree. Excluded folders is a
setting for exactly this reason.
src/model/assetRepository.ts is the same shape for assets, and matches on
type: asset rather than mirroring inventory.base's folder filter — see
Assets above for why.
A packaged Obsidian plugin is three files — main.js, manifest.json,
styles.css — in <vault>/.obsidian/plugins/task-base/. There is no installer
format beyond that.
Pushing a tag that matches manifest.json's version builds and publishes those
three files as release assets:
git tag 0.2.2 && git push origin 0.2.2
Then, on the machine with the other vault:
gh release download 0.2.2 --repo tteschon/obsidian-task-base \
-D "<vault>/.obsidian/plugins/task-base"
Or download the three assets from the Releases page while signed in — the repo being private only means you must be authenticated, not that this stops working. Enable Task Base under Settings → Community plugins afterwards; if Obsidian is already running, reload it first so it rescans the plugins folder.
The release workflow refuses to publish when the tag disagrees with
manifest.json, because Obsidian matches a release to an installed plugin by
that version and a mismatch installs nowhere. npm version patch bumps
manifest.json and versions.json together, which keeps them honest.
ln -s /path/to/repo <vault>/.obsidian/plugins/task-base # after npm run build
One build then serves every vault. Copying the three files by hand works too, and lets each vault run a different version.
The plugin finds tasks by type: task anywhere in the vault, so the
defaults — which describe one particular vault — are usually wrong somewhere
else. Four settings matter:
type: task for an
unrelated reason. This is the one that bites: a vault with Kanban cards or
another type: task convention will otherwise list them in the pane's count
and offer them in the Complete task picker, where choosing one writes
done: true and last done into a note that was never a task.No .base file is required. The pane reads the metadata cache directly, so
every command and every section works in a vault with no base at all. The base
only adds Obsidian's own table view. If you want one, the pane's base button
creates it — that is the whole setup.
Property types need no setup either: Obsidian infers done as a checkbox and
due / last done as dates from the values the plugin writes. The plugin never
touches .obsidian/types.json.
created is a date, vault-widecreated used to hold a wikilink to the daily note — "[[2026-08-31]]" — which
made it text, so it could not be sorted, compared, or used in a formula.
It is now a bare YYYY-MM-DD, registered as date in .obsidian/types.json.
Worth knowing before touching it again: Obsidian property types are
vault-wide, not per-note-type. created was on 259 notes in three
incompatible formats — 194 daily notes carrying ["YYYY-MM-DD HH:MM"], 36
wikilinks, 23 bare dates — so registering the type meant normalising all of
them. Daily notes lost the time component in the process.
The plugin writes the bare form, and the vault's task template matches.
To find tasks it enumerates the vault's markdown files and reads each one's
frontmatter, keeping those with type: task (and type: asset for the asset
picker). That is inherent to the design rather than incidental: a note is a task
because of a property, not because of where it sits, and Obsidian offers no API
to query notes by frontmatter property.
It reads frontmatter only — never the body of a note it was not asked to open — and the asset list is cached rather than recomputed per keystroke. It makes no network requests of any kind, collects no telemetry, and never deletes a note. Everything it writes goes through the vault API, inside the vault.
If you would rather it looked at less, Excluded folders narrows what it considers.
Do not name a method on the view class after a Workspace lifecycle verb.
View.open() is a real method Obsidian calls to open a view, but it is absent
from obsidian.d.ts — so defining private open(task: Task) on the view type
checks cleanly, overrides Obsidian's own method, and the pane then renders
blank: the view constructs, onload and onOpen never run, and nothing is
logged anywhere. It is named openNote for that reason.
render() catches its own failures and paints the error into the pane, so the
next render bug reports itself rather than showing an empty panel.
src/
main.ts commands, view registration, ribbon
settings.ts settings + tab
dates.ts all-day date helpers
recurrence.ts RRULE parse / describe / next-due / build
rruleCompat.ts CJS/ESM interop shim for the rrule package
model/frontmatter.ts pure text: note rendering, YAML normalisation
model/assetLink.ts pure text: asset wikilink <-> bare name
model/completion.ts pure: the branch completing a task takes
model/task.ts the field contract, read + write
model/asset.ts write an asset note
model/note.ts placing a new note: folder, casing, name collisions
model/taskRepository.ts find and bucket tasks
model/assetRepository.ts find asset notes
settingsData.ts pure: settings shape, defaults, migration
ui/ modals and the sidebar view
test/ node:test suites over the pure modules
model/frontmatter.ts holds everything that does not touch the Obsidian API,
which is what makes the test suite runnable outside the app — and those are the
functions most worth testing, since a regex that strays out of the frontmatter
would rewrite someone's note body.
main.js is a bundle: it contains rrule
(BSD-3-Clause). Its notice travels in the banner at the top of every built
main.js as well as in THIRD-PARTY-NOTICES.md.
npm install
echo "$HOME/path/to/YourVault/.obsidian/plugins/task-base" > .vault-plugin-dir
npm run dev # watch build, writes straight into the vault plugin folder
npm test # node:test over recurrence, completion, frontmatter, asset links, settings
npm run lint # eslint
npm run build # typecheck + minified build to the repo root, for release
The dev build destination is per-machine and not committed. It comes from
.vault-plugin-dir (gitignored) or from a VAULT_PLUGIN_DIR environment
variable, which wins if both are set. npm run dev writes main.js,
manifest.json and styles.css there so an Obsidian reload picks the change
up, and exits with an error if neither is set rather than quietly building
somewhere you are not watching. npm run build ignores both and writes to the
repo root.
After the first build, enable Task Base in Settings → Community plugins.
npm version patch && git push --follow-tags
npm version bumps manifest.json and versions.json together and creates the
tag; --follow-tags pushes the commit and the tag as one step. Bumping without
tagging breaks installs silently — the directory reads the manifest to learn
the current version and then downloads the release tagged with it, so a listing
that looks healthy fails for everyone. The Release drift workflow warns on
push and fails on its daily run if the two ever disagree.
Reload it after a rebuild with the Obsidian CLI:
obsidian plugin:reload id=task-base