Journal of my creative projects

UX & coding

Building my own task management system with Obsidian and Node.js — Part 2

The design decisions were the hard part. The build was supposed to be the easy part. It was not.

Building my own task management system with Obsidian and Node.js — Part 2
Photo by Red Shuheart / Unsplash

In the first part of this article, I described the reasoning that led me to build this system rather than adopt an existing one: the failed attempts at Getting Things Done, the discovery of Obsidian, and the design phase in which most of the real decisions were made. This second part is less personal and more technical. It covers the four implementation phases, the bugs that took the longest to understand, what I abandoned along the way, and what I intend to build next.

Four phases, one working system

The build unfolded in four distinct phases, each adding a layer to what came before. I am describing them sequentially here, but in practice the boundaries were messier: a problem discovered in phase three occasionally sent me back to revise something from phase one.

The first phase established the foundation: a parser that reads daily notes and extracts items by section, and a router that sorts those items into buckets without requiring the user to tag anything manually. The section a task is written in determines where it goes. This logic lives in config.js, a single file that serves as the source of truth for all section names and routing rules. Having one canonical map rather than hardcoded strings scattered across the codebase was a decision I made early and did not regret.

The second phase built the interactive digest. The CLI runs in two passes using inquirer, a Node.js library for building terminal prompts. In the first pass, I select up to three tasks for today. In the second, up to five for soon. Everything remaining goes to later automatically, with no individual decision required for each item. An early version asked about each task one by one, today or soon or later, before moving to the next. It was slow, and worse, it asked me to commit to each item in isolation rather than letting me see the full pool first. The two-pass multi-select model was strictly better. The home page is then regenerated completely on each run: an earlier attempt at merging new results with whatever was already on the page caused items to accumulate across runs indefinitely. Full regeneration is simpler, more predictable, and correct.

The third phase was the automation layer. The stated goal was one click to start the digest from anywhere, including from a remote session on my iPhone via Jump Desktop. What that required in practice: an Automator application in the Dock, a shell wrapper script, a specific terminal emulator, and an osascript that activates the terminal and simulates the keystrokes for node index.js. Each individual step is simple. The complexity is invisible to the end user because it is buried inside a chain of wrappers, which is perhaps the most honest description of what "automation" usually means on macOS.

The fourth phase integrated iPhone Reminders. Items captured throughout the day in the 📥 Inbox Reminders list are exported to a local buffer file by a Siri Shortcut, then injected into the current daily note before the digest runs. From that point on, they are indistinguishable from tasks written directly in the daily note. The buffer file is cleared after every run, regardless of what was selected in the triage. Persistence belongs to the daily note, not to a temporary communication channel between two processes.

The bugs

To be honest, several of these took longer to understand than to fix. I am documenting them here because they are the kind of problem that does not appear in tutorials.

The first involved date parsing. The gray-matter library, which handles YAML front matter in Markdown files, automatically converts ISO-formatted dates to JavaScript Date objects. Code that called .split('-') on a date field was therefore operating on an object rather than a string, which produced a silent failure rather than a useful error. The fix was a parseDate() function that checks instanceof Date before typeof string. The lesson was to be explicit about what gray-matter returns, because it does not always return what you expect.

The second was subtler. The extractTaskSections() function in flux-a.js, which is responsible for preserving the task content written by the digest when the home page is regenerated by Flux A, relies on section titles to identify which parts of the page to keep. When a section title changed in config.js without a corresponding change in flux-a.js, the function would fail silently and overwrite the tasks. The resolution was to establish a rule rather than a technical fix: section titles live in config.js as the single source of truth, and any change must be reflected in both files. This is the kind of constraint that belongs in the README, not just in someone's memory.

The third involved Reminders item source tracking. Items exported from the iPhone entered the digest carrying reminders-inbox.txt as their source file. When displayed on the home page, they produced a link to the buffer file rather than to the daily note. When checked off, the cleanup function could not find their source and silently skipped them, leaving ghost entries in the buffer. The fix was to update each item's source attribute immediately after injection into the daily note, before any other processing. From that point on, they behave exactly like items that originated there.

The Hyper terminal automation produced two separate problems. The first was that the Automator "Run Shell Script" action displayed the script content in the terminal instead of executing it, due to quote escaping issues in an inline osascriptcall. Moving the osascript to an external .sh file resolved it. The second was that Hyper does not accept a shell script as a launch argument: passing a .sh path causes it to interpret the path as a directory target. The solution was to have the osascript activate Hyper and simulate the keystrokes for the command, rather than attempting to launch with an argument. This is documented nowhere that I could find; I arrived at it by elimination.

The Siri Shortcut contributed two bugs of its own. The native "Append to File" action, which should have written each Reminder to the buffer file, instead triggered an interactive popup asking me to manually select an item from the list rather than processing the variable it had been given. Replacing it with a "Run Shell Script" action using echo bypassed the behaviour entirely. Marking Reminders as completed produced a similar popup with the "Modify Reminder" action, which was replaced by an AppleScript via osascript. That script introduced a third problem: iterating over the Reminders list while marking items as completed caused an index-out-of-bounds error (-1719) because completed items were being removed from the list mid-loop. The fix was to collect all items into a variable before iterating, then mark them completed in a separate pass.

Finally: inquirer. Version 9 of this library migrated to ES Modules. The project uses CommonJS. Rather than migrating the entire architecture, inquirer is pinned to version 8 in package.json. This is documented explicitly and will need to be revisited if the project is ever migrated to ESM.

What I learned

Every significant pivot in this project came from testing in real conditions, not from design review. The system that exists today is substantially different from the one I sketched during the design phase, and the differences are almost all improvements that only became apparent through use.

The single most important design decision, looking back, was the home page: one note, opened automatically, showing only what is relevant right now. Everything else in the system exists to keep that one page accurate. The digest, the Flux A rotation, the Reminders import, the source tracking, the section-based routing: all of it is infrastructure in service of a single visible surface. When the system works, I open Obsidian and know immediately what I am doing today. That is the whole point.

macOS automation is more brittle than it looks from the outside. Shortcuts' handling of variables inside loops, Hyper's argument parsing, osascript quote escaping inside shell scripts, and gray-matter's silent type conversions are all failure modes that do not announce themselves. They surface in practice, usually at an inconvenient moment, and they require patient elimination rather than immediate diagnosis.

What comes next

The MVP is complete and functional, but it is tied to the Mac mini. Running the digest currently requires either physical access to the machine or a remote desktop session via Jump Desktop. That is acceptable for now and not acceptable indefinitely.

The immediate next step is a Progressive Web App: a small Express server that exposes the existing Node.js logic as HTTP endpoints, with a React frontend that replicates the two-pass digest as a mobile-friendly interface. Installable on the iPhone home screen via Safari, with Tailscale for remote access, reusing all of the business logic already written. No App Store, no developer account, no Xcode. A functional replacement for the CLI within a single weekend, in principle.

The medium-term step is a React Native app built with Expo, using the Express API from the PWA phase as its backend. This would be a proper native application on the home screen, distributable via TestFlight without an App Store submission. It would also be a portfolio project with something to say for itself: a real-world tool, a genuine problem, a documented architecture, and a build history that shows what was tried, what failed, and why the final design is the way it is.

The two options are not mutually exclusive. The PWA comes first, because it validates the API layer quickly and gives me something to use daily. The native app comes second, because it builds on the same foundation and benefits from the PWA as a working prototype. Whether I will actually build the second one depends on how useful the first one turns out to be in practice, which is probably the best thing I can say about any personal project.

< Back