Notifio's desktop UI is mostly a list of searches with a time next to each one. "checked 12s ago". "last check 4m ago". A found listing says when it turned up. There is no chart and no dashboard, because the only question the window has to answer continuously is "is this thing still working", and an age is the cheapest possible answer to it.

All of that comes out of one file with three functions in it:

// app/src/renderer/format.ts /** "12s ago", "4m ago", "2h ago". Empty string for a null timestamp. */ export function relativeTime(at: number | null): string { if (!at) return ""; const seconds = Math.max(0, Math.round((Date.now() - at) / 1000)); if (seconds < 10) return "just now"; if (seconds < 60) return `${seconds}s ago`; const minutes = Math.round(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.round(minutes / 60); if (hours < 24) return `${hours}h ago`; return `${Math.round(hours / 24)}d ago`; } 

I wanted to know exactly what this can say, so I enumerated it rather than reading it.

The vocabulary

Output Reachable values Count
"" timestamp is null 1
just now 0 to 9 seconds 1
Ns ago N = 10 to 59 50
Nm ago N = 1 to 59 59
Nh ago N = 1 to 23 23
Nd ago N = 1 upwards unbounded

So 134 distinct strings before the day branch, which is where it stops being enumerable. Three strings that look like they belong in that table are not in it:

  • 60s ago cannot happen. At 60 seconds the seconds branch has already been passed and the minutes branch takes over.
  • 60m ago cannot happen, for the same reason.
  • 24h ago cannot happen, same again.

Fine so far. Now the other function in the same file, the one that counts down to a search's next attempt rather than up from its last one:

/** "in 45s", "in 3m". */ export function countdown(until: number): string { const ms = until - Date.now(); if (ms <= 0) return "any moment"; if (ms < 60_000) return `in ${Math.max(1, Math.round(ms / 1000))}s`; return `in ${Math.round(ms / 60_000)}m`; } 

That one can say in 60s. The guard is in milliseconds and the rounding is in seconds, so anything from 59,500 ms to 59,999 ms passes the < 60_000 test and then rounds to 60. Two functions in one 28-line file, written on the same afternoon, and they disagree about whether sixty of a unit is a thing you are allowed to say. One of them has a boundary expressed in the unit it prints and the other does not.

Neither is a bug a user will ever report. Both are the kind of thing that only becomes visible when you stop reading the code and start listing its outputs.

Every boundary rounds against you

The more interesting property is the choice of Math.round over Math.floor, which appears four times in those nine lines.

Elapsed time rounds to nearest, so:

  • A listing found 90 seconds ago reads 2m ago.
  • A search checked 1 hour 31 minutes ago reads 2h ago.
  • A find from 23 hours and 30 minutes ago reads 1d ago, half an hour before a day has passed.

Every one of those is the older of the two available answers. For a product whose entire pitch is that you hear about a room first, the clock in its own window is biased toward telling you that you are late. And the countdown rounds the same way, which makes the wait before the next attempt read longer than it is.

The right answer is different for the two directions, and it is not "round to nearest" in either:

  • Elapsed time should floor. Never tell somebody a thing is older than it is. At 90 seconds, 1m ago is true and 2m ago is not.
  • A countdown should ceil. Never promise something sooner than it can happen. in 60s at 59.6 seconds is honest; in 1m would be too, and in 59s would be a small lie.

That is a four-character change in four places, and the reason I have not shipped it yet is the one below.

The clock does not tick at the resolution it prints

The strings are rendered by three components, and because this is React, each one needs something to force a re-render as time passes. That is a twelve-line hook I have written about before, in three clocks in one window at 1s, 5s and 30s. Here is what each call site renders against the vocabulary above:

Component Re-renders every Finest unit it can print
SitesList 1s 1s
MonitorCard 5s 1s
RecentFinds 30s 1s

Only the first row is coherent. The second prints a string that changes every second while being re-rendered every five, so a row that says checked 12s ago is showing you a number that was true some time in the last five seconds. The third is the one that bothers me: the recent finds list prints seconds for the first minute of a listing's life, and repaints twice a minute.

Which means just now, a string with a ten-second window, is rendered by a component that only looks at the clock every thirty seconds. A listing that arrives at the wrong moment never says just now at all. One that arrives at the right moment says just now for the better part of half a minute, by which point it is wrong.

None of the three intervals is a mistake on its own. A finds list that re-renders every second to keep a timestamp fresh is wasteful for a window that sits in the background all day, and 30 seconds is also the polling cadence. The mistake is that the interval was chosen per component and the vocabulary was chosen in one shared file, and nothing connects them. The fix is to pick the tick from the age being displayed rather than per component: a row showing seconds ticks at a second, a row showing hours can tick at a minute.

Why show an age at all

Worth saying, because the alternative is to show nothing.

A monitor that is working looks exactly like a monitor that has silently stopped. Both are a window with a list in it and no new listings. The whole value of printing checked 8s ago next to every row is that a stale number is the one symptom of a dead poll loop that a user can see without opening a log, and the app's log, like most desktop logs, is somewhere nobody is going to look.

The same goes for the finds list, which is a 200-entry ring buffer on disk rather than an in-memory list, one of four caps on our local data. It survives a restart, so the day branch of that formatter is genuinely reachable: open the app on Monday and the bottom of the list is last week. An age is the only thing that distinguishes "three listings this morning" from "three listings, and the newest one is four days old, and you should check whether this search still points at anything".

Twenty-eight lines, 134 strings, three refresh rates, one rounding direction that is wrong in both directions. The app it lives in is on /download, and what it does with your machine while it is open is described on /help.