All work

Case study · Product

Chain

I keep pouring my best hours into the wrong things. So I built a productivity app engineered to get me addicted to the right ones — a streak I can't fake, sitting on an engine most consumer apps never bother to build.

Year
2026
Role
Solo — the Rust/Tauri shell, the pure TypeScript engine, the sync layer, and its most obsessive user.
Tauri (Rust)React 19TypeScriptSQLiteSupabase
Chain's Today screen — a 40-day secured streak, the Copper forge badge, and a momentum multiplier
01

Problem

I don't really have a discipline problem so much as a targeting one. I can work for hours; I just too often pour those hours into whatever's loudest instead of what actually matters. The apps that are best at stealing attention already know how to make a person come back every single day — streaks, levels, little bursts of reward. I wanted to take that exact machinery and point it at deep work instead of away from it.

So Chain is unapologetically a productivity app built to be addictive — on purpose, and first of all for me. The goal was never more features; it's to make sitting down and doing focused work the thing I reach for compulsively, and to make that habit survive the days I don't feel like it. I've been dogfooding it every day for weeks.

02

Constraints

  • It has to be genuinely usable from day one — I'm the test user, and a productivity tool I don't actually open is worthless.
  • The reward has to be real, not gameable. If I can quietly fake a streak or inflate my level, the illusion collapses and the motivation goes with it.
  • The daily bar has to be low enough that a bad day can't break the chain — otherwise one slip and I quit.
  • Native, on the machine where I actually work (Windows) — not another browser tab to ignore.
  • Built to outlive one device. Even before sync existed, every row had to be born ready for it — no schema rewrites later.
03

How it works

Chain is a native desktop app — a Rust/Tauri shell around a React front end — that lives in the tray on the machine I work on. You start a focus session and it counts up (no countdown, no pomodoro); when you bank it, the session sorts into a tier by length: a 20-minute Floor, an hour-long Target, a two-hour Beast, three-hours-plus Legendary. The trick is that the Floor is deliberately trivial. One 20-minute session keeps your streak — your chain — alive for the day. No freezes, no rest days, no back-dating.

Chain's focus timer — a large count-up clock inside a ring segmented into tier bands, with '19m to secure the day' below
One session, counting up. The ring fills toward the tier bands; “19m to secure the day” is the trivial 20-minute floor that keeps the chain alive.

Before the clock starts there's an optional warm-up: you name the one thing you're sitting down to do, and that line rides along on the timer face as an anchor for when your attention drifts. Tray and hotkey quick-starts skip it — the point is momentum, not ceremony.

Under the game is the part I actually care about. All of it — the streak, the XP and levels, 54 medals, the weekly hours, every stat — lives in one pure, framework-free TypeScript engine: no React, no database, no wall clock reaching in. Every number is a deterministic fold over a single append-only log of your sessions. Nothing is stored as a counter I could nudge; history is the only source of truth, and the same log always folds to the same result no matter what order the sessions arrive in — a property I fuzz across 200 shuffled orderings in the tests.

The one place the current time is allowed in is a single injected clock — every rule takes the clock as an argument instead of reaching for it. That boring discipline buys two fun things: the engine is deterministic enough to cover with 284 tests, and a dev “time-travel” panel can shift the clock and seed months of history in an instant — which is exactly how the screenshots on this page were made.

A surprising share of the work is time being correct. Focus days respect your real timezone and daylight saving, so a 23-hour spring-forward day and a 25-hour fall-back day each still collapse to exactly one day — computed on the civil calendar, never by subtracting a fixed number of milliseconds. And once a session is banked its tier is frozen: a later timezone change can never quietly un-earn a badge you already hold.

There's one hand-written data interface with two interchangeable backends behind it: real SQLite inside the native app, plain localStorage in a browser tab — same UI either way, chosen at runtime. The browser build isn't a throwaway mock; it's the whole app with a different storage floor, which is what makes fast iteration (and these very screenshots) possible.

Chain's stats screen — total-focus and worst-day-save cards, a weekly XP ladder, and a 12-month contribution heatmap coloured by tier
Everything here — total focus, worst-day saves, the weekly XP ladder, the 12-month tier heatmap — is folded live from the session log, not read from stored totals.

Multi-device sync is offline-first and conflict-free. Devices exchange session rows stamped with hybrid-logical clocks, and the merge — last-writer-wins for edits, union-earliest for anything that should never be revoked — lives inside that same pure core; the server is a dumb mailbox. Tellingly, your XP and streak are never synced. They're always re-folded from the merged log, so two devices can't disagree about a number that was only ever derived.

On top sits the addictive layer, all of it derived from the same log: an XP “Forge” that re-themes the entire app as you climb through eight material eras (Cord → Iron → Copper → … → Mithril) and unlock accent palettes; 54 medals across twelve categories; a same-day “momentum” multiplier; and a guild where friends race chains and see who's focusing right now.

Chain's Forge screen — the Copper material era at Level 10, the full material ladder, and a grid of unlockable accent colours
The Forge — levels re-skin the whole app through material eras, with accent colours you unlock by leveling or by earning specific medals.
04

Decisions & tradeoffs

The load-bearing decision is that trivial 20-minute Floor. Every instinct says a serious productivity app should push you to do more — bigger goals, higher targets. But high bars punish bad days, and a punished bad day is where streaks die. Making the minimum almost insultingly easy turns loss-aversion into a reason to show up instead of a reason to quit. The streak protecting itself is worth more than any single heroic session — the stats screen even has a “worst-day saves” number that celebrates exactly the days a bare-floor session held the line.

The other load-bearing call was to make the whole reward system a recompute over an immutable session log rather than a pile of mutable counters. It's more work — every stat is derived, nothing is just incremented — but the numbers can't lie, I can re-tune the XP curve later without rewriting my own history, and it's the thing that makes conflict-free sync possible at all. A motivational system you can cheat stops motivating fast.

mutable counters you have to trust
before
every number re-folded from one append-only log
after
284 tests green
The engine

Streak, XP, 54 medals and stats are pure, order-independent folds; determinism is fuzzed across 200 shuffled orderings, and multi-device convergence by a 60-seed two-node property test. Measured via the engine's own vitest suite (284 tests, 17 files, ~1.1s).

I over-built the polish — the animations, the sounds, the leveling, the eight-era Forge — completely on purpose. For a normal utility that would be indulgent. For a thing whose entire job is to be addictive, the polish is the product.

Chain's medal case — 11 of 54 medals forged, an Apprentice rank, and a row of rare Calling Cards
“The Case” — 54 medals across twelve categories, plus rare Calling Cards. Earned sets are recomputed from the log; once a badge is earned it can never be un-earned.
05

What didn't work (yet)

The honest state, up front. The Windows Credential Manager vault for auth tokens is fully written — and currently switched off: I hit a session-lock race in the auth client and, rather than ship something flaky, left tokens in the app's own per-user storage for the beta. The secure path is there, waiting for a calmer moment.

Sync itself was humbling. Getting the conflict-free merge right meant catching five genuine bugs before I'd trust it — a hybrid clock that didn't survive a reboot, a staging race, a cursor that advanced past a failed write, a day boundary that refolded wrong on a remote change, and per-account cursors I'd first let bleed together. CRDTs are easy to feel clever about and easy to get subtly wrong. And it's Windows-only in practice: the tray icon, the always-there drift overlay, the credential vault are all Windows-specific.

And the caveat that outranks all the others: the sample size is still me plus a handful of friends. It works on me — which is a great story and no proof at all that it works for anyone else.

06

Outcome

It came together fast: about fifty commits over five days took it from nothing to sixteen named build “phases” and v0.14 — a real native app with a tray, global hotkeys, crash-resume, a code-signed self-updater pulling from its own releases channel, a Supabase backend, and live multi-device sync — all sitting on a 284-test engine. But the only result I was actually after is smaller and much harder to fake: I open it, I focus, and the chain is the thing I don't want to break.

07

My role

All of it — the Rust/Tauri shell and its native plumbing, the React interface, the pure TypeScript engine and its 284 tests, the offline-first sync layer, and the daily job of being the one user honest enough to notice when it isn't working.