
When I started working on the desktop time tracker for StaffVertex, our employee time tracking and workforce management platform, I thought the hard parts would be screenshots, idle detection and syncing. They were work, sure. But the thing that kept coming back to bite us was much more boring: time itself.
A time tracker produces one number that people get paid against. If that number is wrong by an hour, someone is underpaid or a client is overbilled. And the machine recording it is a laptop that sleeps, crashes, loses Wi-Fi, travels between countries and sometimes sits in the system tray for a week. So "what time is it" turns out to be a real design question.
This post is about the decisions that held up, and three bugs that taught us why they matter.
Rule one: store instants, not local times
Every moment the app records (a start, a stop, an activity minute, a screenshot) is stored as a UTC instant. We never store "9:30 AM" as text. The local day is calculated from the instant at the moment we need it, using the timezone that makes sense for that question.
That sounds obvious until you see how many places want to "just save the local date" because it makes a query easier. Every one of those shortcuts becomes a bug the first time a user changes timezone, or the first time daylight saving moves the clock.
Two clocks, on purpose
Here is a question that sounds simple: when is "today"?
A company in Karachi can have an employee working from Dubai, and another one from London. The company's working day, daily hour limit and reports follow the organization's timezone. But if a notification says "Timer started at 9:02", it has to match the clock in the user's own taskbar, otherwise it looks like the app is broken.
So we split it deliberately:
- Logic follows the organization. Today's total, the daily maximum, day boundaries and reports all use the org's timezone.
- Display follows the device. Every clock time printed in a notification or message uses the user's local clock.
When the two differ, the app shows a one-time note explaining which numbers follow which clock. It shows again if the user switches to an organization in a different timezone. It's a small dialog, but it removed a whole category of "your app shows the wrong time" tickets.
Sessions that cross midnight
Night shifts exist. So do people who forget to stop the timer before dinner.
The tempting shortcut is to stop the timer at midnight and start a new one. We don't do that. A running session is never stopped or split at midnight. At the organization's midnight the app only sends a friendly "new day started, your timer is still running" notification.
The split happens in the maths instead. To get a day's total, every session that overlaps the day is clipped to that day's range, overlapping pieces are merged, and the rest is summed. A session from 10 PM to 2 AM gives two hours to each day without anyone touching the record.
dayTotal = sum(
overlap(session, dayStart, dayEnd)
for each session touching this day
)
Bug story: today's total went 38, 34, 37
A user reported something that looked like a joke. Their "today" counter said 38 minutes, then 34, then 37, in the same hour.
The cause was rounding. The desktop app rounded each session down to whole minutes and then added them up. The server added the raw durations first and rounded once. Depending on which side had fresher data, the user saw a different number. Nothing was lost, but a number that goes backwards destroys trust instantly.
The fix is a rule I now apply everywhere: sum in the smallest unit, round once, and only at display time. Both sides now add milliseconds and round at the very end, so they agree.
Bug story: the 56-year week
This one is my favourite because it's so specific.
In a few real timezones (Santiago, Havana, Asunción and Beirut, among others), daylight saving starts exactly at midnight. On that one day of the year, local midnight does not exist. The clock jumps from 23:59:59 to 01:00.
Our "start of this local day" function didn't expect that. When it couldn't find midnight it fell back to a default, and that default was the Unix epoch, 1 January 1970. One bucket in the weekly report suddenly covered 56 years and swallowed every session in the window. Weekly totals exploded for users in those zones, one day a year.
The fix was to handle the gap properly (use the first moment that does exist that day) and, for the reverse case where midnight happens twice, to use the earlier one. Then we wrote unit tests for those exact zones, because this is the kind of bug nobody will ever reproduce by hand.
A clock that keeps counting while the laptop sleeps
Wall-clock time is not reliable for measuring how long something took. The system clock can be resynced by the OS, adjusted by the user or moved by a timezone change in the middle of a session.
For durations we use a monotonic clock: a counter that only moves forward, keeps counting through sleep and can't be changed from the settings screen. One detail cost us time: the standard Rust Instant type doesn't promise what happens across suspend, and on macOS it pauses while the machine sleeps. So we read the platform's own "time since boot, including sleep" counter on each OS.
This clock is what makes the hard situations sane:
- Sleep and wake. The gap between two heartbeats on the monotonic clock tells us exactly how long the machine slept. A short sleep keeps the session running. After a long one, the session is stopped at the moment the machine went to sleep, not when it woke up.
- Crashes. While tracking, the app regularly stamps a "still alive" marker. After a crash, an open session is closed at the last moment we can actually prove the app was running.
- Forced quit. If the process is being killed, the stop is written synchronously and the database is flushed before exit.
We learned the hard way not to mix the two clocks in one calculation. An early version computed "when did sleep start" as wall-clock now minus the monotonic gap. If the OS corrected the clock during sleep, the stop time moved by the size of that correction. Keeping both sides of the subtraction on the same clock fixed it.
Bug story: the 67.5-hour session
One morning we saw a single session of 67.5 hours. The person had definitely not worked a weekend straight.
The app had been sitting in the system tray for days with an orphaned open row from an earlier state. The "process is alive" signal kept renewing, and crash recovery treated "the app was alive" as "the person was working". We fixed it by trusting the liveness signal only when it agrees with the tracking heartbeat. The lesson was bigger than the fix: a signal that something is running is not evidence that someone is working.
Offline is the normal case
People track time on flights, in cafés and through load-shedding. So the tracker writes everything to a local encrypted database first, and syncing is a separate job that can run later. Time logs and screenshots sync through two independent queues, so a slow screenshot upload never holds time data hostage. If the database is busy, writes go into a pending queue instead of being dropped.
When the data finally reaches the server, time is checked against the server's own clock before it's accepted. I won't go into how that works here, but there is one principle from that design that I think applies to any system that handles money or payroll:
If a record can't be accepted, keep it, explain why, and never quietly change it to make it fit.
Silently "fixing" someone's data is worse than rejecting it, because nobody can ever argue with a number they don't know was changed.
Never trim worked time
The biggest lesson wasn't technical. At one point a well-meant "anti-phantom" pass started stopping timers on timeouts and at midnight, to protect clients from inflated hours. It also threw away real hours people had genuinely worked.
We rolled that back and wrote down rules that every change now has to respect: no midnight stop, no time-based idle auto-stop, no session-length limit. The only time ever removed is idle time that the user or the organization explicitly chose to discard. Integrity checks can limit what is started or claimed, but they never delete work that was really done.
And the users who never update
Desktop apps don't update when you want them to. Some users stay on an old build for months. Every change on the server side is additive: old builds that don't send newer fields keep syncing exactly as before. That constraint shaped the time work more than anything else, because we couldn't fix things by simply requiring everyone to upgrade.
What I'd tell myself before starting
Treat time as a domain, not a column type. Decide which timezone each question belongs to. Measure durations on a clock nobody can change. Round once. Write tests for the strange zones. And whenever a rule could cost someone a paid hour, make it prove its case before it touches their data.
Next I want to write about how the same "evidence first" thinking shaped the server side of StaffVertex, and why our API for desktop clients is only ever allowed to grow, never change.