Every focus tracker I tried had the same fatal flaw: to record that I was focused, it first made me unfocus. Open a browser tab, log in, dismiss the upsell. The fire selling extinguishers.
So I built focusd on the opposite principle: the tool goes to where the work happens. My work happens in tmux and Neovim, so my focus scoreboard lives in the tmux status bar and the Neovim statusline. This post is the technical tour: one Go daemon, one SQLite file, and the handful of design decisions that made it feel invisible.
The architecture in one diagram
┌───────────────────────────┐
tmux status-line ──▶ │ │
Neovim (lualine) ──▶ │ focusd · 127.0.0.1 │ ──▶ ~/.focusd/focus.db
HTMX dashboard ──▶ │ Go · Echo · embed.FS │ (SQLite, WAL)
your own scripts ──▶ │ │
└───────────────────────────┘
One daemon. One database. Everything else — the tmux bar, the nvim plugin, the web dashboard — is just a window onto the same state. The timer belongs to the server, not the editor: start a focus in Neovim, close everything, and tmux keeps counting. One source of truth.
SQLite done right: WAL, one connection, prepared statements
The daemon opens SQLite in WAL mode with a single connection and immediate transactions:
db, err := sql.Open("sqlite3",
"file:focus.db?_journal_mode=WAL&_busy_timeout=5000&_txlock=immediate")
db.SetMaxOpenConns(1)
Three decisions hiding in that snippet:
- WAL lets the status-line endpoints read while a write is in flight. Readers never block.
-
SetMaxOpenConns(1)sounds like a limitation; for a localhost daemon it's a superpower.SQLITE_BUSYsimply cannot happen between my own goroutines — the pool serializes access, and prepared statements stay valid forever. -
_txlock=immediatemakes write transactions take the lock atBEGINinstead of at first write, converting a class of mid-transaction busy errors into a clean wait at the door.
Killing a TOCTOU with a CHECK constraint
"Only one active focus session at a time" is a classic check-then-act race: two POST /focus/start requests both read "no active focus", both insert. Instead of a mutex, I let the schema enforce the invariant:
CREATE TABLE active_focus (
id INTEGER PRIMARY KEY CHECK (id = 1),
habit_id INTEGER NOT NULL REFERENCES habits(id),
since TIMESTAMP NOT NULL
);
The table physically cannot hold two rows. The second INSERT fails with a constraint violation that maps to a clean HTTP 409. No locks in application code, no race, and the invariant survives even a rogue script writing to the database directly — which is the whole point of local-first: the file is the API of last resort.
The rule that shaped everything: never cost the user focus
A focus tracker that adds latency to your editor is self-defeating. Two integrations, one rule — the daemon must be allowed to die without anyone noticing:
tmux polls with a hard budget:
curl --silent --max-time 0.4 "$FOCUSD_URL/status" 2>/dev/null
If the daemon is down, the component prints nothing and tmux renders a clean bar. 400ms worst case, once a second, and the /status endpoint returns plain text — no JSON parsing in shell.
Neovim never blocks on I/O. The lualine component reads a cached string synchronously; a libuv timer refreshes that cache in the background by spawning curl (vim.loop.spawn), reading stdout, and waiting for the exit+EOF handshake before trusting the buffer:
-- statusline reads the cache; the cache is refreshed off the main loop
local status_cache = ""
local function refresh()
spawn_curl({ "--max-time", "0.4", BASE_URL .. "/status" }, function(out)
status_cache = vim.trim(out)
pcall(vim.cmd, "redrawstatus")
end)
end
Editor hiccups: zero. If the daemon is stopped the cache goes empty, and the statusline segment collapses to nothing. Silence when idle is a feature: no gray icon, no "0m", no cognitive noise.
HTMX + embed.FS: a dashboard with no build step
The web panel is server-rendered HTML with htmx for fragment updates — and htmx itself is embedded in the Go binary with embed.FS, so the dashboard works fully offline:
//go:embed views/* static/*
var viewsFS embed.FS
Creating a habit returns an HTML fragment plus an out-of-band swap that updates the side panel in the same response. No JSON API for the UI, no npm, no bundler — go build is the whole frontend pipeline. The panel is themed Catppuccin Mocha to match the terminal, because opening it should feel like your terminal grew a second screen, not like leaving home.
Boring operational correctness
The unglamorous parts that make a daemon trustworthy:
-
signal.NotifyContextfor graceful shutdown: SIGTERM drains in-flight requests, then checkpoints the WAL sofocus.dbis always a clean single-file backup (cpis the backup strategy). -
Bind
127.0.0.1only. A personal tracker has no business listening on your LAN. -
The ctl script health-checks pid + port together (via
lsof), sofocusd statuscan't be fooled by a stale pidfile or an unrelated process squatting on 8080.
What I deliberately didn't build
No accounts, no sync, no telemetry, no Electron. Every one of those would trade the user's focus or the user's data for my convenience. Backup is cp, export is sqlite3, auditing is opening the file. Software you can hold in your head ages well.
focusd is $20, one-time, lifetime — prebuilt binaries for macOS (ARM/Intel) and Linux (static): https://9482969453720.gumroad.com/l/aopuhv
Happy to go deeper on any of this in the comments — especially the single-connection SQLite take, which I know is spicy.