Personal project — Windows widget, downloadable
A Windows widget that tracks which app or window is actually in focus, down to the browser tab or open file since Chrome and VS Code both put that detail directly in their window title and runs entirely on your own machine: no account, no cloud, nothing leaves the computer very locally. It groups distracting apps and sites into categories (like Social Media or Games) with a daily time budget each based on user's choosing; going over is visible in the widget, though it doesn't interrupt what you're doing yet. It can also generate a Markdown report of tracked time, for my own use as a contract worker keeping a work log.
Most simple screen-time trackers stop at the app level: "Chrome was open for 3 hours." That number is close to useless, because Chrome being open is true whether you're reading documentation for work or scrolling social media: same app, completely different value. The same problem exists for a code editor: "VS Code was open" tells you nothing about what type of work was being done.
AccountabilityApp exists to close that granularity gap, while keeping everything local since this is a personal tool anyone can download and run for themselves without account, server elsewhere or data leaving the machine. That local-only constraint shaped almost every decision below, including one that mattered more than expected: how much distribution friction is worth paying just to get tab-level detail, once it turned out a simpler mechanism could get most of it for free.
Tracking (see what's actually being used) and blocking (quota-based, visible but not yet enforced)
A background poller checks whatever window has focus every 3 seconds and logs it as a session in the app's own local, encrypted database. That alone gives app-level detail like any basic tracker: "Chrome was open for 3 hours." The useful part is that Chrome and VS Code both write the active tab's title or the open file's name directly into their own window title bar, so the exact same generic check happens to yield tab-level and file-level detail for the two apps used most, without any app-specific integration for either.
active-win-pos-rs::get_active_window() is polled every 3 seconds and diffed against the previous focused window; a change closes out the prior app_sessions row and opens a new one. end_crash_session() closes any row still open on startup, so a crash mid-session can't leave one open forever. The database itself is SQLCipher-encrypted via rusqlite, with the key held in the OS keyring rather than on disk.
Rather than blocking a fixed list of sites or apps, the app groups them into categories (Social Media, Games, more can be added) and gives each category a daily time limit, matched against both domain-style keywords and app/window-title keywords. Right now, going over the limit shows up clearly in the widget such as name, used time, a progress bar, but doesn't interrupt anything; there's no overlay or popup stopping you from continuing, since that enforcement lived in the extension that got cut (see below).
Each category stores a daily limit in minutes, an enabled toggle, a manual pause toggle, and editable domain/app keyword lists. Usage is aggregated across app_sessions and whatever session is currently open, and the collapsed widget shows a live indicator for whichever enabled, unpaused category the focused app or window belongs to, refreshing on the same 3-second poll as the rest of the tracker.
The first version of tab-level tracking didn't get simplified but got deleted, on purpose.
The original plan for tab-level tracking was a dedicated bridge: a WebSocket server inside the Tauri app, a Chrome extension reporting every tab switch to it, and eventually a matching VS Code extension. It got built, and it worked — tab sessions recorded correctly, category quota blocking triggered an overlay and rotating deterrent popups on over-budget tabs, 12/12 extension tests passed, and a real WebSocket origin-spoofing vulnerability (any webpage's JavaScript could otherwise connect and inject forged tab events, since WebSocket isn't subject to CORS the way fetch() is) got found and fixed before it ever shipped.
It still didn't ship. Distributing a Chrome extension to real users means either publishing through the Chrome Web Store or asking every user to manually flip on Developer Mode and click "Load Unpacked", friction that wasn't worth paying once it became clear that app-level tracking already captures nearly the same signal for free, since Chrome and VS Code both write the active tab's title or the open file's name directly into their own OS window title, which the app was already reading for every other app on the system.
Removed in v0.2.0: the ws://127.0.0.1:7734 WebSocket server, the tab_sessions table, and the unpacked extensions/chrome extension (blocking overlay injection, rotating deterrent popups, a 15-second heartbeat to catch users who stay on a tab without switching away). A VS Code/Cursor client was specced but never built — Chrome was proven first, and the removal decision landed before a second client was worth starting. Nothing about the removal was a rush call: the extension carried its own fixed vulnerability and a full passing test suite right up to the point it was cut, which is what made "we already have most of this for free at the app level" a comfortable trade rather than a shortcut.
None of these surfaced from reasoning about the code or from anything the test suites caught, only from actually using the feature.
The settings screen lets you edit which domains and app names count toward a category — but every keystroke reset the cursor to the beginning of the field, making it functionally impossible to type something like "x.com" one character at a time.
Every onChange event called a Tauri command to save the value immediately, and the resulting store refresh re-rendered the input with the freshly-read database value mid-keystroke — overwriting whatever the user had just typed. Fixed by giving the input its own local draft state: onChange only updates the local draft, and the actual save fires once on onBlur, when the user clicks away. The lesson generalises: any input wired directly to a round-trip save on every keystroke is a cursor-jump bug waiting to happen the moment that round-trip is even slightly slower than typing speed.
The Social Media category's app keyword "x" added for the X (formerly Twitter) taskbar app was also matching the letter x anywhere inside another window title. That included "Windows Explorer," so ordinary file-browsing time was silently getting counted toward the Social Media quota. It wasn't caught by reasoning about the matching logic; it was caught by reading an actual generated report and noticing "Windows Explorer" showing up somewhere it shouldn't.
contains_keyword did a plain substring match with no word-boundary check, so a single-character keyword like "x" matched inside any word containing that letter. Fixed by requiring the match not be flanked by another alphanumeric character on either side, so "x" still matches a window titled "X" but no longer matches inside "Explorer." New tests cover both the false positive and the still-intended match. The default keyword list was cleaned up in the same pass — twitter.com/twitter were stale post-rebrand duplicates of x.com.
"Clear All Data" is meant to wipe tracked history and start clean. Instead, the widget's timer sat frozen at zero right after clearing, and only started counting again once the user switched to a different app, a confusing dead window right when the feature is supposed to prove the reset actually worked.
Two separate state machines fell out of sync: the Zustand store wasn't refreshed after the database clear, and the Rust tracking thread kept holding an in-memory session pointer to a now-deleted row. Fixed on both sides as the store now calls refreshStats/refreshSessions right after clearing, and a needs_reset flag on ActivityTracker makes the Rust thread drop its stale session and start a fresh one on the very next poll tick, instead of waiting for a real app switch to notice anything changed.
Tracking and category quota tracking both work end to end on Windows, backed by 31/31 backend tests and 35/35 frontend tests, all through app/window-title matching — no browser extension or editor plugin required after the Chrome bridge got cut. Going over a category's daily limit is visible in the widget but doesn't interrupt anything yet: no blocking overlay, no deterrent popups, that enforcement layer left with the extension. There's also no "strict mode" equivalent — pausing a category is a simple toggle with no added friction, and stays paused until manually re-enabled rather than timing out on its own. Backend commands for app-level blocking (add_blocked_app, remove_blocked_app, get_blocked_apps) already exist with no widget surface built yet — the next planned piece.
Four more sessions building a feature to turn the tracked data into something billable and along the way, finding a real data-integrity bug that had been running silently for months.
The original tracking was for self-accountability, but the same data which app or tab was in focus, and for how long, is also exactly what you'd want as a record for contract work: proof of whether a fixed-price job actually took more or less time than it was quoted for. The feature that came out of this: press download, get a plain Markdown log of everything tracked since the last download, with a header and a subtotal.
Getting the download to actually work took two attempts. The first approach appeared to fail silently with nothing visible happened on click. It turned out to have worked the whole time; the file was just never confirmed by anything the app could see. That distinction mattered enough to switch to a mechanism that could actually prove success back to the user, rather than one that merely hoped it had worked.
The first attempt used a plain browser-style download (Blob + <a download>). It did work as the file was written, just discovered afterward via File Explorer rather than through any signal the app received. The real gap: a browser download click and the file finishing writing are different events, and only the click is observable from script, even in a real browser. Replaced with tauri-plugin-dialog for a native Save-As picker, paired with a Rust command that writes the file via std::fs::write — here, the invoke() promise resolving is real proof the bytes were written, since it's the app's own code doing the write. That's what made an in-app "Saved" confirmation possible at all.
Windows Terminal is where most AI-assisted coding sessions actually happen, but the tracker had no way to see which tab was active inside it unlike Chrome or VS Code. Two different ways to close that gap got seriously investigated: reading Windows Terminal's tab state directly, and hooking into Claude Code's own session events. Both turned out to have real, structural problems once traced through an actual example, not just a hypothetical one.
Rather than force either approach to work, the honest call was to leave that gap where it is. The report now shows Windows Terminal as a single lump of time with no per-tab detail, and the user relies on their own memory to separate real work from procrastination inside that block, a real limitation, stated as one, instead of a debatable heuristic pretending to be data.
Windows Terminal renders its tab titles as custom XAML content and never writes them back to the underlying Win32 window title — confirmed by reading the tracking crate's source (a plain GetWindowTextW call, working exactly as intended) and corroborated by two open Microsoft repo issues confirming no public API exists to query tab state. The Claude Code alternative — hooking UserPromptSubmit to push events to the existing WebSocket server — was specced in detail, then traced through a real sequence (prompt → switch to Chrome → switch back) and found to produce overlapping, not sequential, time ranges: Claude Code hooks only fire on Claude Code's own internal events, never on "the user alt-tabbed away," which is the one thing the existing OS-level tracker can see for free and a hook structurally cannot.
The very first generated report showed a window starting at 'the start of today aka midnight' despite the actual time. The report's fallback for "since the last report" is start-of-today when no checkpoint exists yet except "today" was being computed as UTC midnight, which in Melbourne lands mid-morning local time. The same bug quietly affected the pre-existing dashboard "today" stats too, not just the new report.
Fixed with a today_start_timestamp() helper that computes local midnight via chrono::Local and resolves it back to a UTC instant properly, including the ambiguous-time case around a DST transition. Since get_sessions_today already delegated to this helper for the dashboard, the one fix corrected both call sites at once.