← Notes


7 min read

Building Team Tracker: what time tracking taught me about time

How a privacy-first work tracker went from an empty repo to an auto-updating Windows app, a Chrome extension and a live dashboard in 69 pull requests, and the bugs that made it honest.

Our teams at Neuraforz and Exzelon needed something like Hubstaff: a way to see who is working, for how long, and on what, without paying per seat for a tool that records far more than we want. So I built Team Tracker. It has a Windows desktop app, a Chrome extension and a web dashboard for managers.

The first commit landed on 3 July 2026. Version 1.1.7, the build everyone runs today, shipped on 8 August. In between were 69 pull requests, and most of the interesting ones were bug fixes. This note is about those.

The one rule: whether, never what

Before any code, I wrote one line at the top of the project guide: we capture whether you are working, never what you type. Every design decision since has been checked against it.

  • Activity comes from Windows GetLastInputInfo, which only says how long ago there was any input. The agent samples it about once a second and reports the share of active seconds. There is no keyboard hook, and no key codes are ever read.
  • The browser extension logs time per domain (github.com, docs.google.com), never full URLs or page content.
  • Nothing runs off the clock. Attendance and tracking start when you press Start work, not when you open the app.

A tracker people resent is a tracker people work around. Making the privacy line visible in the product is what makes the rest of it acceptable.

Week one: engine first, window later

The stack settled on day one: NestJS and Prisma for the API, Next.js for the dashboard, MongoDB Atlas for data, and Rust for the desktop agent. I started on Postgres and moved to Atlas the same afternoon, so there is no database server to run.

The riskiest part was the capture-and-sync engine, not the window around it. So the first desktop build was a headless Rust CLI that captured a sample, queued it in local SQLite, uploaded a batch and checked that the server deduplicated a second upload. Only after that loop was proven did it get a Tauri 2 shell with a tray icon, a consent screen and an installer.

That spike paid for itself immediately. The API validated time entry ids as UUIDs, but MongoDB ids are ObjectIds, so every real upload would have failed. A unit test with fake ids would never have caught it. One end-to-end run did.

By the end of 8 July the product also had teams, an org tree, attendance with a calendar, screenshots stored in Backblaze B2, a leave tracker and employee requests.

Time is harder than it looks

The bug I'm proudest of fixing is the one that made the numbers lie.

Attendance showed Worked as the span from clock-in to clock-out. One member's row read 17h 40m for a single day. The Hours column right next to it, built from actual activity samples, said 2.7h. The app had been left open while the laptop slept, and the session span kept counting.

The fix was to stop trusting spans. Each activity sample now stands for a 10-minute window, the windows are clamped to the day and merged where they overlap, and worked is the total of what was actually captured. A day can no longer hold more than 24 hours of work, and an idle open app adds nothing.

Two things made this fix good rather than lucky:

  • The agent already uploaded raw samples, so the fix was server-side only. Every historical row corrected itself on deploy, with no app update needed.
  • A week later the same inflated number turned up in Reports, which computed worked time its own way. The lesson: one definition of worked, in one place.

Days themselves needed care too. Attendance first grouped by UTC day, which put a morning in Kolkata on the wrong date. Overnight sessions needed to show the next day starting at midnight, with the exit counted only on the day you actually logged out.

Five extension versions in two days

The Chrome extension looked simple: count seconds on the focused tab, per domain, in 10-minute buckets. Manifest V3 made it humbling.

  • 0.1.1: an 8.5-minute YouTube video showed as about 1 minute. The worker stopped counting after 60 seconds without input, which is exactly what watching or reading looks like. Now it counts until the screen locks, not until the mouse stops.
  • 0.1.3: time still went missing. When the MV3 service worker woke from suspension, the focus check briefly returned nothing, and the code wiped the running session. The fix split "credit elapsed time" (safe on every timer tick) from "re-check focus" (only on real tab, window or lock events).
  • 0.1.4: long sessions capped at about 1 minute per window. Each flush deleted the bucket after sending, the next minute recreated it, and the server's upsert set the total back to 60 seconds. Buckets became cumulative counters that are re-sent until their window closes.

Then the opposite bug: one 10-minute bucket held 24,358 seconds. A machine had slept for hours, and on waking the extension credited the whole gap at once. That got a guard on both sides: the extension caps any single credit at 3 minutes and treats a longer gap as time away, and the server refuses to store more than 600 seconds in a 600-second bucket. A one-off script cleaned up the rows that were already inflated.

An offline queue needs a way to give up

The desktop agent buffers everything in SQLite, so an unplugged laptop loses nothing. But one user saw a permanent red Sync error banner. Two queued samples pointed at a time entry that no longer existed, the server answered 404 every time, and the agent retried forever.

The fix was to sort errors by kind. 400, 404 and 422 mean the batch can never succeed, so it gets quarantined: marked aside, kept on disk for debugging, never retried. Auth errors, 429, 5xx and network failures are temporary and stay in the queue. Retrying everything sounds safe, but one poison batch can block the whole queue.

The screenshot gallery had a quieter version of the same problem. Someone asked why their screenshots started at 2:06 pm when they had clocked in at 10:30. Capture was perfect. The API simply returned the newest 24 shots. It now returns a full day per person.

Live, with a safety net

The dashboard's Overview updates in real time over Server-Sent Events. I used fetch with a streamed body instead of EventSource, because EventSource can't send an Authorization header and I didn't want tokens in URLs.

The API runs on Vercel serverless, which cuts long connections, so the old 20-second polling stayed as a fallback. Only one source runs at a time: if the stream drops, polling takes over and the stream reconnects with backoff. Nobody sees the switch.

Shipping updates to people who never update

A desktop app is only as current as its least-patient user, so v1.1.5 to v1.1.7 were about updates.

  • The source repo is private, and its release files can't be downloaded without a token. The updater reads from a small public repo that holds only signed installers and a latest.json file.
  • Every update is checked against a public key built into the app. An installer that isn't signed with the matching private key is rejected.
  • Update now shows a progress bar, installs and relaunches. Later downloads in the background and installs when you close the app.

Then 1.1.6 shipped with a progress bar that never moved, and a close button that left the app stuck. The cause was one missing file. Tauri 2 gates the frontend's event API behind a capabilities file, and the app had none. Commands called with invoke() don't need that permission, so login and tracking worked fine, but every listen() was refused without an error. Adding a capabilities file that grants the default permissions fixed both bugs in 1.1.7.

Where it sits now

Team Tracker runs for our own teams, and a machine-to-machine API feeds its activity data into Exzelon's performance platform. Installers are at neuratracker.neuraforz.com.

If I built it again, I would keep three habits:

  1. Prove the risky loop headless first. Capture, queue, upload and dedupe, end to end, before any UI.
  2. Store raw facts, compute the numbers on the server. It's why the attendance fix could correct history without shipping an app update.
  3. Write the bug down before fixing it. Every fix above started as a short plan with the real numbers in it: 17h 40m against 2.7h, 24,358 seconds in a 600-second bucket. Concrete numbers kept each fix honest.
  • Rust
  • Tauri
  • Chrome extension
  • NestJS
  • MongoDB
  • desktop apps

Written by Masum Kabir Biswas, AI/ML Engineer in Kolkata, India. Get in touch