DEV Community

Shiah Kwhrow
Shiah Kwhrow

Posted on

How I built a local-first habit tracker with Go and SQLite (zero friction, zero cloud)

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 ──▶ │                           │
                     └───────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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_BUSY simply cannot happen between my own goroutines — the pool serializes access, and prepared statements stay valid forever.
  • _txlock=immediate makes write transactions take the lock at BEGIN instead 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
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.NotifyContext for graceful shutdown: SIGTERM drains in-flight requests, then checkpoints the WAL so focus.db is always a clean single-file backup (cp is the backup strategy).
  • Bind 127.0.0.1 only. A personal tracker has no business listening on your LAN.
  • The ctl script health-checks pid + port together (via lsof), so focusd status can'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.

Top comments (0)