I wanted a time tracker I could actually trust. Something that would follow me between my laptop, my phone, and my watch without dropping state or forcing me to pick one device as the "source of truth." Every commercial tracker I tried was either a bloated SaaS with a monthly bill, or a single-device app that pretended sync was someone else's problem.
So I built TimeLogger — a multi-device tracker where a small Rust binary is the brain and a trio of native Swift apps (iOS, macOS, watchOS) are the faces. Everything converges on one SQLite file. No cloud account, no subscription, no telemetry.
This post is about the shape of that system and the decisions that actually mattered.
The Mental Model
There's exactly one canonical place where state lives: a single SQLite database, owned by a Rust binary called tl. Every other surface — the CLI, the TUI dashboard, the iOS app, the menu-bar app, the watchOS face — is a client that reads and writes through the same HTTP API.
┌────────────────────────────────────────┐
│ Rust binary `tl` │
│ · CLI / TUI │
│ · HTTP server (axum, port 9746) │
│ · BLE peripheral (optional) │
│ · iCloud / CloudKit (optional) │
│ · SQLite (single source of truth) │
└────────────────────────────────────────┘
▲
┌───────────────────┼───────────────────┐
│ Wi-Fi (mDNS) │ BLE │ iCloud
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
│ iOS │ │ watchOS │ │ macOS │
└───────────┘ └───────────┘ └───────────┘Every design question — "where does this value get computed?", "who owns this timer?", "what happens on conflict?" — collapses once you commit to one canonical store. The Rust process decides. Everyone else asks.
The Rust Side
The binary is a Cargo workspace with feature flags so the default build is lean. Plain cargo install --path . gives you the CLI, TUI, and HTTP server. Opt into the platform-specific transports when you need them:
cargo install --path . --features "ble,icloud"A single executable wears several hats:
- CLI —
tl start,tl stop,tl log --week,tl todo add. Quick operations you want in muscle memory. - TUI —
tl uidrops you into a ratatui dashboard for browsing entries and managing running timers without leaving the terminal. - Server —
tl servestarts an axum HTTP server on port 9746 and announces itself over mDNS as_tl._tcp. - Storage — rusqlite on a file at
~/Library/Application Support/time-logging/data.db. The CLI and the server share it, so starting a timer from the command line instantly shows up on the iPhone.
The axum routes map directly to the domain:
POST /api/v1/timers/start
POST /api/v1/timers/{id}/pause
POST /api/v1/timers/{id}/resume
POST /api/v1/timers/{id}/stop
GET /api/v1/entries?week=true
GET /api/v1/analytics?range=month
POST /api/v1/syncNothing clever — just REST, because every platform speaks it and I don't need anything more.
Three Transports, One Protocol
The harder question was: how do clients talk to the server when they're not on the same Wi-Fi? I ended up supporting three transports, each with a narrow job:
Wi-Fi + mDNS
The default. The server broadcasts _tl._tcp and the apps resolve it with Bonjour on iOS / NetService on macOS. No configuration, no IP addresses to type — if you're on the home network, the apps just find the server.
Bluetooth LE (optional)
When the iPhone leaves Wi-Fi but I'm still near the laptop, I still want timers to sync. The Rust binary exposes a BLE peripheral (CoreBluetooth, bridged via an Objective-C shim in ble_peripheral.m) that speaks the same commands over GATT characteristics. Same protocol, different pipe.
iCloud (optional)
The catch-all. When I'm on mobile data, miles from the laptop, changes queue into a CloudKit container and reconcile next time the server sees the record. Slow but reliable — and Apple handles the delivery.
The apps try them in that order: Wi-Fi first, fall back to BLE, fall back to iCloud. The client never cares which one succeeded.
The Swift Clients
Each platform has its own personality. Fighting that was a mistake I only made once.
iOS
Five tabs — Timers, Entries, Todos, Analytics, Settings — plus Home Screen widgets, Lock Screen widgets, and Live Activities so a running timer stays visible in the Dynamic Island. The widget extension shares state with the app through an App Group (group.com.raminsharifi.TimeLogger).
macOS
A proper SwiftUI app with a matching tab layout, but the real trick is the menu-bar item. It shows the current timer's elapsed time in the status bar and exposes start/stop/switch as a popover. Global hotkeys let me stop a timer from anywhere without touching the dock.
watchOS
The one that surprised me. I started assuming the watch would be a thin client, but it ended up being the device I use the most — because reaching for a wrist is friction-free. Vertical-page tabs (Timer → Todos → Breaks → Log → Settings), complications, and a watch widget. When the iPhone isn't nearby, it syncs straight to the Rust server over Wi-Fi.
All three apps use XcodeGen so the .xcodeproj files are generated from a committed project.yml. No merge conflicts on binary Xcode projects, ever. This alone saved hours.
brew install xcodegen
cd ios && xcodegen generate && open TimeLogger.xcodeprojThe Sync Model
Each client keeps a local SwiftData store so the UI stays snappy and works offline. When it reconnects, it posts to /sync:
{
"client_id": "iphone-ramin-2026",
"last_sync_ts": "2026-04-15T22:10:00Z",
"local_changes": [ /* entries, todos, timer events */ ]
}The server merges by last_modified timestamp, returns server-side changes plus id mappings (local UUIDs ↔ server ids), and tracks tombstones so deletions propagate instead of resurrecting on next sync. Last-write-wins is enough for a personal tracker — CRDTs would be overkill for data I only edit from one device at a time.
The one bit of real coordination: if a second device starts a timer while another device has one running, the server pauses the older timer. Otherwise you'd quietly double-count an hour when you forgot to stop your laptop timer before starting one on the watch. It happened to me exactly once before I added the check.
Break Periods as Protobuf Blobs
A small quirk worth mentioning: break periods inside a timed session (lunch, meetings, whatever interrupted the work) are stored as protobuf-encoded blobs on the entry row, schema in proto/time_logging.proto. I originally modeled them as a separate breaks table, but the join was noise for something I basically always read alongside the entry. Collapsing it into a blob made the sync payload smaller and the queries simpler.
If I ever need to query across breaks (for aggregate "how much time did I spend on lunch"-style analytics), I'll denormalize then. Until then, protobuf blob, move on.
What I'd Do Differently
A few things I got wrong and fixed, or got wrong and am living with:
- I started with Core Data on the Swift side, then migrated to SwiftData. Worth it. SwiftData's
@Modelmacros eliminated an entire class of boilerplate. If you're starting an Apple-platform app today and you target iOS 17+, just use SwiftData. - I underestimated mDNS flakiness. On some routers, Bonjour goes quiet after the phone sleeps. I now re-announce on a 30-second interval from the server. It's not elegant, it works.
- BLE was too much effort for the payoff. Honestly, 95% of my sync happens over Wi-Fi. If I were starting over I'd put BLE behind a feature flag from day one (which I eventually did) rather than treating it as a core pillar.
- One SQLite file for CLI + server was the best decision in the project. Every time I've been tempted to split it (a separate
tl-serverbinary, a server-specific DB), I've talked myself out of it. Shared state eliminates entire categories of bugs.
Why This Project Matters to Me
I track time because I'm bad at it. Left to my own recollection, every day feels like "a lot of work" and no week feels like progress. Logs don't lie. At the end of a month I can look at tl log --month and see where the hours actually went, which is almost always different from where I thought they went.
The tool itself isn't the point — the habit is. But the tool needs to be frictionless enough that the habit sticks. Three taps on a watch face. One keystroke in a terminal. A menu-bar click. That's the budget.
If you want to poke at the code, the repo is github.com/raminsharifi/time-logging. Happy to hear how others are tracking their time — and especially happy to hear where this design would fall apart at your scale.