DEV Community

Daniel Pertu
Daniel Pertu

Posted on

The store decides what is new, and the banner follows

For most of Notifio's life, a find existed only as an email.

The app watches rental search pages, spots a listing that was not there on the previous check, and sends you an alert. If you missed the email, or the send failed, or you simply wanted to see what turned up while you were asleep, the app itself had nothing to show you. The activity log said 3 new listing(s) on Kamernet - Leiden and the links were somewhere in a mailbox.

Fixing that is a hundred-line file. The interesting part is not the storage, it is which component gets to decide what counts as new.

Why not reuse the snapshot we already have

Notifio already stores, per search, the set of listings the page held last time. That is the diffing baseline. It is the obvious place to look up "what have we seen", and it is the wrong place to keep history, for a reason that only shows up in use:

/**
 * It is separate from the per-search snapshots on purpose: those are a diffing
 * baseline keyed by URL and get deleted whenever a search is re-pointed, which
 * is exactly when the user least wants their history to vanish.
 */
Enter fullscreen mode Exit fullscreen mode

Baselines are keyed by search URL and thrown away when a search is removed or edited. That is correct for a baseline: a URL you are no longer watching has no meaningful previous state, and comparing against a stale one produces nonsense.

It is exactly wrong for history. Narrowing a search from "Leiden, any price" to "Leiden, under 900" is a normal Tuesday afternoon action, and it should not delete the record of the eleven rooms the app found you this morning.

Two stores, because they have two lifetimes. That is the whole justification, and it is worth writing down in the file, because a year from now the duplication looks like an oversight.

A ring buffer on disk, and nothing cleverer

export interface Find {
  /** Listing id (origin + path), unique per listing. */
  id: string;
  siteName: string;
  /** The search URL this came from, so the UI can group by search. */
  siteUrl: string;
  title: string;
  url: string;
  foundAt: number;
}

const MAX_FINDS = 200;
Enter fullscreen mode Exit fullscreen mode

Two hundred, chosen against an actual usage pattern rather than a round number instinct: a busy hunt produces a few hundred finds a week, and the question this feature answers is "what did I miss overnight", not "what did I find in March". Nobody scrolls to 500. An unbounded list, meanwhile, is a file that grows forever on a user's disk in exchange for rows nobody opens.

The siteUrl field is there so the UI can group by search, which sounds obvious until you notice that the same room can legitimately appear under two different saved searches, and the user's mental model is "this search found that", not "the app found that".

The store returns what it accepted

Here is the bit I would lift into another project unchanged:

export function add(items: Omit<Find, 'foundAt'>[]): Find[] {
  if (items.length === 0) return [];
  const existing = read();
  const seen = new Set(existing.map((f) => f.id));
  const foundAt = Date.now();
  const fresh = items.filter((item) => !seen.has(item.id)).map((item) => ({ ...item, foundAt }));
  if (fresh.length === 0) return [];
  write([...fresh, ...existing].slice(0, MAX_FINDS));
  _listeners.forEach((fn) => { try { fn(fresh); } catch { /* a broken listener must not lose the find */ } });
  return fresh;
}
Enter fullscreen mode Exit fullscreen mode

add returns the items it actually stored, not the items you handed it. So the caller notifies about exactly those:

const fresh = finds.add(newListings.map((listing) => ({ /* ... */ })));

if (fresh.length > 0 && config.notifications?.desktop !== false) {
  notifyFinds(site.name, fresh, log);
}
Enter fullscreen mode Exit fullscreen mode

The alternative, which is what almost every version of this starts as, is for the monitor to decide what is new, fire the notification, and then tell the store about it. That gives you two components each holding an opinion about the same question, and they disagree the first time anything is retried, re-checked on demand, or re-diffed after a page reload glitch. The symptom is a user getting buzzed twice about the same room, which on a product whose entire promise is "the buzz means go and reply right now" is worse than not buzzing at all.

Making the write the arbiter collapses that. There is one set of ids, in one place, and the notification is downstream of it. If add says nothing was new, nothing was new.

Worth noting the ordering in the monitor too: the local record and the desktop banner happen before the email is sent. The banner costs no network round trip, and on a rental site the first few minutes are the whole difference between replying and reading about a room someone else got. Every hop you can do locally, you should do first. I wrote about the hop-counting argument in why "instant" notifications are never instant.

The failure modes are all resolved the same way

This file is a history feature. The product feature is the alert. So every failure in here resolves towards "the monitor keeps running":

function read(): Find[] {
  if (_cache) return _cache;
  try {
    const parsed = JSON.parse(fs.readFileSync(FINDS_PATH, 'utf8'));
    _cache = Array.isArray(parsed) ? (parsed as Find[]) : [];
  } catch {
    // Missing or corrupt: an empty history is a fine place to start and must
    // never stop the monitor.
    _cache = [];
  }
  return _cache;
}
Enter fullscreen mode Exit fullscreen mode

A corrupt JSON file loses your list of finds, which is annoying. A corrupt JSON file that throws out of the poll loop stops you being told about rooms, which is the product not working. The Array.isArray check is there because JSON.parse of a file that somehow contains null or {} succeeds, and .map on it a moment later does not.

Same instinct on the listener loop: a subscriber that throws must not take the find down with it. And the same on the write path, where a half-written file is a real possibility on a desktop app that a user can quit at any moment:

const tmp = `${FINDS_PATH}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(finds), 'utf8');
fs.renameSync(tmp, FINDS_PATH);
Enter fullscreen mode Exit fullscreen mode

Write, then rename. rename is atomic on the same filesystem, so a crash leaves either the old file or the new one, never a truncated one. That pattern shows up in six files in this app: the config, the settings, the entitlement cache, the reply ledger, the snapshots and this. It costs one extra line.

Pushing rather than polling

The listener list exists for exactly one subscriber:

finds.onAdd((fresh) => broadcast({ type: 'finds', finds: fresh }));
Enter fullscreen mode Exit fullscreen mode

The app's window is a renderer talking to a local Express server over server-sent events. Hanging the broadcast off the store rather than off the monitor means a find shows up in the window at the same moment the desktop banner appears, rather than on the next refresh, and it means the UI cannot show a listing the store failed to write.

This matters more than it sounds because Notifio lives in the system tray and its window reloads itself every time you re-show it, which I wrote about in the window reloads every time you open it, so it cannot own the state. Anything the renderer is the only copy of is a thing you are about to lose. Disk plus a push is the cheap fix.

The rule underneath all of this

Whichever component is allowed to write the record is the component that decides what happened. Everything else, notifications, UI, streams, reads its answer from there.

It is a one-line change to add's return type and it deletes a whole category of double-notification bug, because there is no longer a second place holding an opinion.


Notifio is a desktop app that watches rental search pages and tells you the second something new is posted, instead of waiting for a portal's alert email to work through a send queue. There is a per-site breakdown at notifio.app/alerts, including Kamernet and Rightmove, a setup walkthrough at /help, and the installers are at /download.

Top comments (0)