[Systems, Full-Stack]

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.

Browser SPA Phone Worker · Hono session gate /api/t/* · /timer/* · /chat Hyperdrive Postgres Mac daemon launchd · OBS daemon API secret-gated R2 recordings, 60d cookie pooled secret multipart reconcile Dashed lane: recording path. Solid lane: the request path a page load depends on.
Figure 1. What runs where. The recording lane is deliberately off the path that a page load depends on.

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.

PATCH /api/t/tasks/:id One handler, four gates 1 · session cookie or 401 2 · table in TABLES or 404 3 · body ∩ spec.cols 4 · order from spec, never input Postgres parameterised GENERATED lifecycle_state is_atom · actual_seconds dropped before the write A table is a row of config: cols it may write, filters it may narrow on, and a fixed order. Adding a table is data. The handler never changes.
Figure 2. The generic table gate. Adding a table is a config entry; the handler never changes.

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.

duration_minutes the estimate — what you claimed task_sessions one row per work session started_at / ended_at timer or manual entry AFTER trigger sums closed sessions re-rolls both sides when a session moves tasks row estimate actual_seconds both denormalised here, so a list needs no join The gap between the two columns is the only honest feedback the system produces.
Figure 3. Estimate and actual are separate columns by design, and the trigger keeps the second one true.

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.

task_sessions WHERE ended_at IS NULL at most one row, ever UNIQUE INDEX ON ((1)) WHERE ended_at IS NULL Desktop Phone Second tab Daemon now() − started_at now() − started_at now() − started_at reconciles OBS No client holds the clock, so nothing has to be synchronised. Every surface derives the same elapsed time from one timestamp, and a reload recovers it for free.
Figure 4. Four surfaces, one timestamp, no synchronisation protocol.

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.

1 · SPA probes localhost /whoami on its own daemon null on a phone 2 · start carries it preferred_machine written on the session row 3 · every daemon polls same row, same answer no election, no lock hostname matches claims · records writes recording_machine does not match stands down no work, no contention The decision is made at the click, by the only party that knows the answer, and written once. Coordination becomes a string comparison against a row every daemon can already see. A start with no preference falls back to whichever daemon takes it first.
Figure 5. Coordination reduced to a string comparison against a row every daemon can already see.

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 More Work]

See the next project ↓

[AI Product, Founder]
atlas.finneykoshy.com/companies
Atlas CRM
Quick actions /

Records

People
Companies
Agents
Logs
Projects
Pipeline
Analytics
Finney Koshy

Companies

71
∞ Find companies Show closed (89) + Add
≡ Sort ≡ Filter
Company Stage Industry People Notes Added
OpenAI
Reply received AI / Foundation Models
SA GB
ChatGPT, GPT-4, Codex May 9
Anthropic
Intro booked AI / Foundation Models
DA
Claude, Constitutional AI May 9
Stripe
Message sent Fintech / Payments
PC JD
Online payments infra Apr 21
Vercel
Interviewing Dev tools / Infra
GR
Frontend cloud, Next.js Apr 10
Linear
Reply received Dev tools / PM
KH
Issue tracking for SaaS Apr 21
Figma
Person identified Design / Collab
DF
Collaborative design tool May 9
Notion
Message sent Productivity
IZ
Docs, wikis, projects Apr 10
Airbnb
Message sent Marketplace
BC
Marketplace for stays Apr 21
Shopify
Person identified E-commerce
TL
E-commerce platform May 9
Ramp
Reply received Fintech / Cards
EG KM
Corporate cards & spend Apr 10
Discord
Message sent Comms / Social
JV
Voice, video, chat Apr 21
Spotify
Reply received Consumer / Audio
DE
Music streaming Apr 10
Coinbase
Message sent Crypto / Fintech
BA
Crypto exchange Apr 21
Plaid
Person identified Fintech / Infra
ZP
Banking API infrastructure May 9

Atlas

A career CRM that runs the job search like a sales pipeline.

[AI Infrastructure, Systems]
app app app dialect A dialect B dialect C app app app Onefold one surface A B C every client learns every dialect every client learns one switching model rewrites four things switching model changes a string The four things: request shape, parameter names, stream protocol, error body.

Onefold

An OpenAI-compatible gateway that speaks every provider's dialect.