TasQ Tab - Task Manager Desktop App
Why two-way state reconciliation always needs three explicit operations, and what happens when you skip one.
I’ve been building a personal task widget for the last few weeks. It’s called TasQ Tab — an Electron desktop app that sits always-on-top in the corner of my monitor and surfaces tasks, calendar events, Gmail, and timers in one tile. The newest piece is a realtime sync with ClickUp, where most of my actual work lives.
I shipped the sync in three rounds. Each round felt like the final one. None of them were. This is the story of why.
Round one: the parent/subtask routing bug
The first realtime sync (v1.25, then refined in 1.26) did what you’d expect: poll ClickUp’s /team/{teamId}/task endpoint every 15 seconds, compare the response against the local copy, and update or insert as needed. The endpoint returns tasks with nested subtasks. Easy.
Except every subtask was getting created as a new top-level task in the widget. Not nested under its parent — sitting at the root, duplicated, ugly.
The bug was that I’d been treating the array of tasks as flat. ClickUp returns subtasks both as nested children of their parent AND as top-level entries in the response (when they were recently updated). My code was iterating the array and pushing every entry into state.data.tasks as if it were a parent. Subtasks ended up as orphan parents.
The fix took me a couple hours: split the response into parents (those with r.parent null) and subtasks (those with r.parentset), process parents first to ensure they exist locally, then route each subtask into its parent’s subtasks[] array. Net change: +30 lines. Confidence: 100% solved.
That was v1.27. I closed the project, wrote the build log, moved on.
Round two: parent deletion
A few days later I deleted a task in ClickUp and it didn’t disappear from TasQ Tab. Same as before — the data was already in the response (or rather, NOT in the response: a deleted task simply stopped appearing). My code just wasn’t acting on the absence.
I added a “reconciliation” full-pull every 30 seconds. On reconcile, build a Setof every task ID currently in ClickUp, then walk local linked tasks. If a local task’s clickup_task_id isn’t in that set, soft-delete it (set deleted_at, move to archive — the user can restore from the trash icon if they need to).
Also: if it reappears later (rename, undelete), un-archive it on the next reconcile. Defensive symmetry.
That was v1.29. Net change: +50 lines. The project folder got its own four-character pipeline (brainstorm to prompt to build to echo), with a folder name like clickup_realtime_sync and a brain entry in _brain/insights.md.
I closed it. Moved on. Felt good about my “complete” sync.
Round three: the third dimension
Today I deleted a subtask in ClickUp’s web UI. It didn’t disappear.
I almost didn’t notice. The parent was still there. The other subtasks were still there. Just one subtask, stale, in the local copy.
I sat with that for a minute.
The bug had the same shape as the previous two. The data was in the response — ClickUp’s task object returns a subtasks[] array; a deleted subtask is simply missing from that array. My existing applyClickUpToLocalTask function walked the array to UPDATE existing local subtasks (matching by clickup_task_id). It never REMOVED local subtasks whose ID was absent from the remote array. Same blind spot as before, just one level deeper.
The fix was small. A new removeOrphanedSubtasks(localTask, remoteSubtasks) helper. Build a Set of remote IDs. Walk local subtasks in reverse (so splice indices stay valid). If a local subtask has a clickup_task_idbut it’s not in the remote set, splice it out. If a timer was running on it, null out the active timer first — don’t try to log the time to a ClickUp task that no longer exists.
Net change: +35 lines. Took an hour from brainstorm to installer.
The pattern I should have seen the first time
Looking back, all three rounds were the same fix repeated at different scopes:
Two-way state reconciliation needs explicit logic for three operations: ADD (the remote has something new), UPDATE (the remote changed something we have), DELETE (the remote no longer has something we have). Skip any one of these and bugs surface in months, not days.
Round 1 was a missing ADDdimension — subtasks were getting added wrong because the code didn’t distinguish them from parents. Round 2 was a missing DELETE dimension at the parent level. Round 3 was a missing DELETE dimension at the subtask level. Three rounds, same blind spot in three different costumes.
This is obvious in retrospect. It’s the kind of thing you’d find in a textbook chapter on data synchronization. But I didn’t see it the first time because the UPDATE path is what feels like the work. Updating a status, propagating a name change, syncing a due date — that’s the visible code. The ADD and DELETE paths feel like edge cases when you’re writing them. They get noticed when a user does something the visible code doesn’t anticipate.
The lesson, written down so I remember it next time:
When you build a sync, for every remote source-of-truth, write three explicit blocks. Update is not enough. Add is not enough. Delete is not enough. Write all three and label them.
What didn’t work first
A side note worth recording — not because it relates to subtask deletion, but because it relates to building widget UIs in Electron.
Between v1.27 and v1.39 there were eleven CSS iterations on a custom-HTML timezone picker. Eleven. Each iteration was a different font size or padding value or line-height tuning, trying to make the picker’s dropdown rendering match the visual quality the user had liked in earlier versions.
Round eleven was the worst. The picker was readable, sort of, but wrong in some way I couldn’t pin down. The user said “the very first version that has a timezone looks good.” That was a flag.
The very first version had used a native HTML <select>element. The browser renders those with the operating system’s preferred font, line-height, and subpixel rendering tuned for that font on that DPI. We had replaced it with a custom HTML popup specifically so we could add a search box. Eleven rounds of CSS later, we still hadn’t matched what the OS gave us for free.
The fix was to revert. Keep the native <select>. Add the search box as a separate input above the row of selects. Use option.hidden = true/falseto filter the visible options in each select. Browser’s native dropdown does the rest.
That was v1.37. Net change: minus 370 lines deleted, plus 90 added. Deletion build.
The lesson there is different:
When CSS in a CSP-strict renderer can’t match OS-native rendering for a particular widget after a few rounds, revert to the native widget and add features around it. Don’t keep iterating CSS.
Both lessons go in the brain. Both are easy to write down and hard to remember in the moment.
Why I’m publishing this
Three rounds for a complete sync, eleven rounds for a small UI element. It’s tempting to clean up the history before publishing — to write a blog post that says “I built a realtime sync from ClickUp to a desktop widget” and skip over the bugs and the resets. To make it look like a straight line.
I don’t think that’s useful. The straight line is fiction. The real history is the one where I had to ship the same fix three times because I kept missing a dimension. The real history is the one where I burned eleven hours on font size before realizing I’d built the wrong widget.
If you’re building two-way sync into your own tool, save yourself a round. Write the three blocks. Label them ADD / UPDATE / DELETE. Test each one explicitly.
If you’re building a desktop widget in Electron, save yourself eleven rounds. Use the native widget unless you have a specific reason not to. The OS is better at this than you are.
TasQ Tab is a personal tool, open source under MIT, at github.com/Shinikara08/tasQtab. If you’re curious about the architecture or want to do something similar, drop a note.
Working on something like this?
Start a conversation →