Notifio is a desktop app whose UI exists to show the state of a background process: which searches are being watched, when each was last checked, how long it took, and what turned up. Almost every number on screen is relative to now.
"Last check 12s ago" is a lie four seconds later. There are two ways to deal with that. One is to store the formatted string and recompute it on an event. The other is to re-render on a timer and let the formatter read the clock. We do the second one, through a hook that is twelve lines long:
/**
* A clock that re-renders on an interval, so relative times and countdowns
* stay honest without every component owning a timer.
*
* Pass `active: false` when nothing on screen is counting down: a monitor
* sitting idle overnight should not be re-rendering once a second.
*/
export function useNow(intervalMs = 1000, active = true): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!active) return;
const timer = setInterval(() => setNow(Date.now()), intervalMs);
return () => clearInterval(timer);
}, [intervalMs, active]);
return now;
}
It is used in exactly three places, at three different intervals, and in all three the return value is discarded.
The three call sites
// SitesList.tsx: the list of searches, with a retry countdown on blocked ones
const hasCountdown = sites.some((s) => {
const nextRetryAt = app.runtimeFor(s)?.nextRetryAt;
return nextRetryAt !== null && nextRetryAt !== undefined;
});
useNow(1000, hasCountdown || app.status === "polling");
// MonitorCard.tsx: the start/stop card with the summary line
const ACTIVE_STATUSES: MonitorStatus[] = ["running", "idle", "polling", "replying"];
const isActive = ACTIVE_STATUSES.includes(status);
useNow(5000, isActive);
// RecentFinds.tsx: the list of listings found so far
useNow(30_000, app.finds.length > 0);
Three intervals, three conditions, and no two of the conditions are the same kind of thing. One is "is any row on screen counting down", one is "is the monitor in one of four states", one is "is this list non-empty".
The intervals come straight from what the smallest unit on screen is. A countdown says in 45s, so it needs a second. A summary line says last check 12s ago for something that happens every 30 seconds, where being five seconds behind is invisible. A find is typically minutes old by the time you look at it, so half a minute is plenty.
A hook whose return value nobody wants
useNow returns now, and all three call sites call it as a statement. That looked like a mistake the first time I read it back, and it is not: the re-render is the product. The formatters read the clock themselves.
/** "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`;
}
Date.now() inside the function rather than a now parameter is the thing that keeps the call sites short. A row renders relativeTime(runtime.lastCheckedAt) and nothing has to be threaded down through props. The price is that the function is not pure, so nothing about it is memoisable and nothing about it is testable without stubbing the clock. For three formatters in a UI I will take that; in a scoring function or anything whose output is stored, I would not.
Two details in there I did care about. Math.max(0, ...) keeps a clock skew between the main process and the renderer from rendering "-3s ago", which was a real thing I saw once. And the ten second "just now" floor exists because a counter that visibly increments 1s, 2s, 3s next to a line of text reads as a stopwatch, and nobody needed a stopwatch.
The countdown formatter has the same shape with one more guard:
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`;
}
Math.max(1, ...) stops it rendering "in 0s", and the past is "any moment" rather than a negative number, because the retry it is counting down to is scheduled by the monitor and fires on its own cycle. The UI does not know the exact instant, only that it is due.
What switching a clock off actually costs
The comment in the hook says the goal is that an idle monitor should not re-render once a second, and that is a real concern in a desktop app that people leave running all day while hunting for a flat.
But switching a clock off means the text it was keeping honest stops being kept honest. With the monitor running this does not show, because the main process streams per-search state snapshots and the renderer gets a frame at most every 120ms, so something re-renders those rows constantly anyway. The clock is only load-bearing in the gaps.
Where it does show is with the monitor stopped. app.status === "polling" is false, no row has a retry scheduled, so the 1s clock is off and nothing else is arriving. A row reading "checked 2m ago" will still read "checked 2m ago" five minutes later, until any other state change re-renders the list. I know about this and have left it, for a reason I can defend and do not love: a stopped monitor's last-check time is a historical fact, and the user's own actions (opening a panel, toggling a search) refresh it in practice. The honest version would be a slow clock that is always on, say 60 seconds, so the number is never more than a minute wrong.
The 30 second clock has a smaller version of the same gap. It is gated on app.finds.length > 0, and because the effect depends on active, the interval starts fresh the moment the first find arrives, so the first tick is a full 30 seconds later. The arrival itself re-renders the list, so the row paints as "just now" and is correct for ten seconds and plausible for thirty. Good enough is doing a lot of work in this paragraph, and I think that is the right call for a timestamp on a row you are about to click.
Why three intervals and not one shared clock
The obvious tidy-up is one clock in context at 1s, with everything else derived from it. I am fairly sure that is worse here. A clock in a provider re-renders every consumer below it, and this window has a log pane whose list is capped at 200 lines sitting in the same tree. Per-component intervals mean a tick re-renders exactly the component that asked for it, which is why the find list is allowed to be lazy while the countdown is not.
And none of this survives the window being closed, because the window reloads itself every time it is opened from the tray. Every clock here starts at Date.now() on mount, which is one of the few advantages of a renderer that is allowed to forget everything.
The general thing, which took me three call sites to notice: a relative timestamp is not a value you compute, it is a value with an expiry date, and the only question worth asking is how wrong it is allowed to be. Answer that per screen and the interval falls out. Answer it once globally and you end up with a UI that re-renders all night to keep a number nobody is looking at.
The window all of this is in is on the download page for Mac and Windows, what each panel shows is described on the help page, it runs on a one-time licence, and the searches whose timers these are get set up per site, with a guide for each at notifio.app/alerts, including Kamernet and Pararius.
Top comments (0)