OpenTodo is a self-hosted, local-first task manager: a Go server with SQLite, a React app embedded in the binary, and sync built on a field-level operation log. Every device applies changes locally first and converges with the others later. Version 0.9.0 came out today. The user-facing story is on the project blog. This post is about four problems from building it that turned out more interesting than they looked.
1. A title parser that only reads what you just typed
Quick add has always understood Call Anna tomorrow 3pm p1 #home. The obvious next step was to do the same when you edit an existing title: add tomorrow p1 at the end and the task gets a date and a priority.
The trap is the rest of the title. Parse the whole string again and "Review may report" suddenly gains a May date. That was there all along, but nobody asked for it.
The fix is to read only the edit. When editing starts, the current title becomes the base. On commit, the parser finds the common prefix and the common suffix of the base and the draft, treats those spans as if they were quoted, and reads only the text in between. Whatever it recognises becomes one patch: scalar fields overwrite, labels are added to the task's labels, and reminders become new records. The function is pure, with no reads and no writes, so it is easy to test with a table of before/after pairs. The whole change is undone by one Undo.
The parser has a Go twin (the CLI and the importers use it), and both are pinned to the same JSON test vectors, so #Home/Docs resolves to the same project in the browser and in ot add --natural.
2. One running timer, even when two devices started one offline
Time tracking has one rule that users care about: one timer runs at a time. On a single server that is a uniqueness check. In a local-first app it isn't. Your laptop and your phone can each start a timer while offline, and both writes are valid when they are made.
Refusing one of them at sync time would lose work. Instead, the rule is a deterministic repair that every device can compute on its own:
- Among a user's running entries, the one to keep is the one with the greatest
started_at, with ties broken by the greatest id. - Every other running entry is ended at the kept entry's
started_at.
Because every device sees the same entries and applies the same ordering, every device writes the same ended_at values. The repairs converge like any other edit, and nobody's tracked time disappears; it just stops where the newer timer began. The rule lives in a small Go package with no dependencies besides ciphertext detection, and the TypeScript side is its twin, pinned by shared fixtures.
3. Importing issues without ever writing back
0.9.0 can turn the GitHub or GitLab issues assigned to you into tasks. The design decision that shaped everything else was that nothing is ever written back. The task follows the issue (completed when it closes, renamed when it's renamed), and that is all.
"Follows" needed a precise meaning, because the user can also edit the task. The rule: your own edits stand until the issue changes the same thing again. If you rename the task and the issue keeps its title, your name stays. If the issue is renamed later, the issue wins for that field.
Two more constraints come from running on someone else's server:
- The server owner opts in. The feature is off by default, and the personal access tokens are kept outside the database and its backups.
- Requests go only to the host you typed. Every request goes through one outbound policy: http(s) only, no private-network targets unless the owner allows them, the connection pinned to the address that was checked, and no redirects followed. The same policy covers webhooks and calendar subscriptions, so a typed URL can't be turned into a probe of the server's own network.
4. Encrypting a field as one value
Tasks now remember where they came from: a captured page, an email's Message-ID, an issue link. The field is a small object, {kind, provider?, id?, url?, title?}.
For accounts with end-to-end encryption, the server never sees task content. The question was which parts of the origin count as content. kind alone looks harmless, but url, title and id name the page, the mail or the issue, and even the combination leaks something. So the origin is encrypted as one whole value: the object is serialised, then encrypted into one ciphertext string. The server accepts that string wherever it would accept the object, stores it and serves it back unchanged. Another unlocked device decrypts it and shows the chip as usual.
It also fits the sync model. The origin is one last-writer-wins register, like the due date, so two concurrent origins never mix fields from both.
How it was built
Every change above started as a written spec, using the OpenSpec workflow: proposal, design, spec deltas, tasks, then code. That sounds heavy for a one-person project, but it is what made it possible to do most of this release in a few days with AI coding agents doing the typing. The specs are the contract the agents work against and that I review, and they are all in the repository.
OpenTodo is AGPL-3.0. You can try it without installing anything on the demo, read the 0.9.0 release notes, or run it with one container: ghcr.io/pixari/opentodo:stable.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.