Building a Task System That Argues With You
Chamber holds an estimate and a measured actual for every task, and puts the disagreement between them in front of you. This is how the timer, the table API and the recording daemon are built, and what each of them gets wrong.
Role
Sole engineer
Scope
Domain modelling
API design
Edge runtime
Local daemon + storage
Stack
React, Vite, TypeScript, TanStack Query
Hono on Cloudflare Workers, Better Auth
Postgres via Hyperdrive, R2, launchd daemon
It is Tuesday and there is a task on the list that says rewrite the onboarding email. You wrote it last week and you gave it fifteen minutes, because when you wrote it down it felt like a fifteen minute job. Everything feels like a fifteen minute job when you are writing it down.
You start it at 9:40. At 10:25 you are still in it, three tabs deep in an old doc, and you have not yet written a sentence anyone will read. You finish at 11:10 and tick it off. The list records that a fifteen minute task is done.
Nothing in the system knows it took ninety. Next week you will write a task like it and give it fifteen minutes again, and you will do that forever, because the tool you are using has no opinion about the difference between what you claimed and what happened.
That gap is the only useful signal a task manager can produce, and almost none of them keep it. Chamber is built around keeping it.
Background
Parkinson's law says work expands to fill the time available for its completion. The useful corollary for a task system is that an item with no declared cost has no boundary to expand against, so it absorbs whatever you have. Perfectionism is what makes the available time unbounded; a declared cost is what bounds it.
So Chamber asks for a number up front. Every task carries duration_minutes, the claim. A task under the configured cap is an atom; anything over it is a weight, an outcome you have not decomposed yet. The cap itself lives in a settings row rather than in code, because the right number is a personal calibration and it changes.
A claim that is never checked is just a wish, though. The second half of the system measures what actually happened, and the rest of this is mostly about the surprising amount of machinery that takes.
Requirements
Five properties shaped every decision that follows.
- Calibrating: Every task carries an estimate, and every task accumulates a measured actual. The gap between them is the output of the system, not a side effect of it.
- Single-clocked: A running timer means the same thing on a laptop, a phone and a second tab. No device may hold the authoritative elapsed time.
- Recoverable: Closing the tab, losing the network or reloading mid-session must not lose time or leave a timer running forever.
- Closed by construction: Derived state — whether a task is an atom, whether it is open or done, how long it actually took — is computed by the database, never asserted by a client.
- Private: Single tenant, one login. There is no public read path to task data and no third-party analytics anywhere in the app.
Solutions considered
- Estimates alone: The shape most tools take: you predict a duration and nothing ever checks. Predictions never improve because nothing disagrees with them.
- Actuals alone: Pure time tracking measures faithfully and teaches nothing. Without a claim to compare against, a four-hour task and a four-hour afternoon look identical.
- Client-held timers: Simplest to build and wrong on the second device. Two tabs disagree, a reload resets, and a closed laptop silently drops the session.
Keeping both numbers is the only option that produces feedback, and it is the one that costs the most to build. The estimate is a column. The actual is a timer, a session table, a rollup, four client surfaces that must agree on the time, and — because I wanted to see where the ninety minutes actually went — a daemon recording the screen.
Architecture
The whole application is one Cloudflare Worker. It serves the built SPA, gates the API on a session cookie, talks to a self-hosted Postgres through Hyperdrive, and exposes a second, secret-gated surface for the recorder daemon. There is no separate backend to deploy and no service mesh to reason about.
Two lanes matter. The solid one is the request path a page load depends on and it has to be fast. The dashed one is the recording path, and it is allowed to be slow, retry, and fail without anybody noticing.
One handler for every table
Six tables are reachable from the browser and none of them has a hand-written endpoint. A table is a row of configuration: the columns a client may write, the columns a list may be filtered on, and a fixed sort order. The handler reads that configuration and refuses everything outside it.
Three details do the security work. The writable column list is an allow-list, so a new column is invisible to clients until someone adds it deliberately. The sort order is a constant in the source, never user input, which removes an entire class of injection and unbounded-sort problems. And a set of generated columns — lifecycle_state, is_atom, actual_seconds — is readable but never writable, because the database owns them.
That last one is the reason this design holds. A client cannot declare a task an atom, cannot mark itself done, and cannot assert how long something took. It can only supply facts and let Postgres derive the rest.
The honest cost of this is that one function is now load-bearing for every read and write in the product. A bug in the column projection is a bug in all six tables at once. It trades a large surface of boilerplate, each piece individually reviewable, for a small surface that has to be exactly right — a good trade for one engineer, and a worse one for a team where review is the safety net.
Keeping both numbers
The estimate is a plain column on the task. The actual is not stored on the task at all — it is the sum of sessions, one row per stretch of work, each with a start and an end. Sessions come from the timer or from a manual entry, because work done away from the keyboard still counts and pretending otherwise corrupts the calibration.
Summing sessions on every read would be correct and slow, so the rollup is denormalised onto the task row by an after trigger. The subtlety is the update case: when a session moves from one task to another, both tasks are stale, so the trigger re-rolls the old side as well as the new one. That is the kind of correctness that is easy to forget in application code and hard to forget in a trigger, which is most of the argument for putting it there.
Where the clock lives
The obvious way to build a timer is to start one in the browser. It works until the second device, and then it is wrong in a way that is tedious to fix: two tabs disagree, a reload resets the count, and a laptop that goes to sleep drops the session without telling anyone.
So no client holds the clock. A running timer is a row with a start and no end, and every surface renders elapsed time as the difference between now and that timestamp. The phone and the desktop agree because they are doing the same subtraction against the same row, not because they are synchronised. A reload recovers the timer for free, since recovering it is just reading the row again.
One timer runs at a time, and that is enforced by the database rather than by the code that starts timers. A unique index over a constant expression, restricted to rows that have not ended, makes a second concurrent session impossible to insert. The constraint cost about one line and removed the need to trust every call site.
Deciding which machine records
While a timer runs, a daemon on the Mac drives OBS to record the screen and camera, uploads the file to object storage when the timer stops, and attaches it to the session. Recordings expire after sixty days and play back in the app with seeking, which works because the worker serves them with range requests rather than shipping whole files.
The interesting problem is not the recording. It is that more than one machine may be running a daemon, all of them watching the same database, and exactly one of them should start recording — the one you are actually sitting at. The database cannot tell which that is. The browser can: it probes its own localhost for a daemon and asks it who it is.
So the answer is decided at the click, by the only party that knows it, and written onto the session as a preference. Every daemon then polls the same row and compares a hostname. One matches and claims the work; the others stand down. There is no election, no lease and no lock, because the decision was made before there was anything to contend over. A start with no preference — from a phone, where there is no daemon to ask — falls back to whichever daemon takes it first.
What the system guarantees
At most one timer runs, and that is a schema property rather than a promise made by application code. Completing a task closes its running session server-side, so finishing work on the phone cannot strand a timer on the desktop. Every surface showing elapsed time is showing the same arithmetic over the same timestamp. And no client can write a derived field, so the atom flag, the lifecycle state and the accumulated time cannot drift from the facts they are computed from.
What is still wrong with it
The single-tenant assumption is everywhere. The session is the only gate, rows are addressed by id alone, and the one-running-timer index is global rather than per-user. Adding a second person is not a feature, it is a migration through every table and a rewrite of that constraint.
The database carries dead tables. Three of them, plus two foreign keys on the task row, left over from a feature set that was removed. Nothing reads them. They survive because dropping a column is the one migration that cannot be undone in a hurry, and I have not been annoyed enough yet.
A timer is only honest if you start it. The measured actual is not the truth, it is the part of the truth you remembered to record. Manual entry exists to patch this, which means the calibration depends on a habit — precisely the kind of dependency the rest of the design works to remove.
The daemon assumes a Mac and an OBS install. The claiming protocol is general; the recorder is not. A second platform means a second recorder, and the neat part of the design would survive that while the messy part would not.
The takeaway
Most of the good decisions here were the same decision: push the invariant down until something other than a person is responsible for it. One timer is an index. The accumulated time is a trigger. Whether a task is an atom is a generated column. Which machine records is a string written before the race could start.
What is left for the application to do is ask for a number, measure what happened, and show you the two side by side. That turns out to be enough. The list stops being a place where optimistic estimates go to be forgotten, and starts being the thing that tells you how wrong you usually are.
See the next project ↓
Records
Companies
71| Company | Stage | Industry | People | Notes | Added | |
|---|---|---|---|---|---|---|
| | Reply received | AI / Foundation Models | SA GB | ChatGPT, GPT-4, Codex | May 9 | |
| | Intro booked | AI / Foundation Models | DA | Claude, Constitutional AI | May 9 | |
| | Message sent | Fintech / Payments | PC JD | Online payments infra | Apr 21 | |
| | Interviewing | Dev tools / Infra | GR | Frontend cloud, Next.js | Apr 10 | |
| | Reply received | Dev tools / PM | KH | Issue tracking for SaaS | Apr 21 | |
| | Person identified | Design / Collab | DF | Collaborative design tool | May 9 | |
| | Message sent | Productivity | IZ | Docs, wikis, projects | Apr 10 | |
| | Message sent | Marketplace | BC | Marketplace for stays | Apr 21 | |
| | Person identified | E-commerce | TL | E-commerce platform | May 9 | |
| | Reply received | Fintech / Cards | EG KM | Corporate cards & spend | Apr 10 | |
| | Message sent | Comms / Social | JV | Voice, video, chat | Apr 21 | |
| | Reply received | Consumer / Audio | DE | Music streaming | Apr 10 | |
| | Message sent | Crypto / Fintech | BA | Crypto exchange | Apr 21 | |
| | Person identified | Fintech / Infra | ZP | Banking API infrastructure | May 9 |
Atlas
A career CRM that runs the job search like a sales pipeline.
Onefold
An OpenAI-compatible gateway that speaks every provider's dialect.