DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Five rooms in one search is one banner, not five

Notifio watches rental search pages and tells you the moment a new listing appears. For most of its life it told you by email, which I have already written about at length: counting the hops an "instant" notification actually travels was the post that made me stop trusting the word instant.

What that post did not say is the obvious follow up. If every hop costs you, the channel with no hops wins. The app is already running on the user's machine. It found the listing. It can put it on screen before the email has left the building.

So it now does, and the file that does it is 83 lines. Here is what is interesting in them.

The ordering is the feature

Inside the poll loop, once a search has produced new listings:

// The local channel goes first. It costs no network round trip, so the
// banner is on screen before the email has left the building, and on a
// rental site the first few minutes are the whole difference.
const fresh = finds.add(
  newListings.map((listing) => ({
    id: listing.id,
    siteName: site.name,
    siteUrl: site.url,
    title: listing.title,
    url: listing.url,
  }))
);
if (fresh.length > 0 && config.notifications?.desktop !== false) {
  notifyFinds(site.name, fresh, log);
}
Enter fullscreen mode Exit fullscreen mode

finds.add returns only the items it actually stored, which is the whole reason it returns anything at all. A listing that was already in the history does not produce a second banner, and the dedupe lives in one place rather than being repeated by every consumer.

The email goes out after this, from a queue built earlier in the cycle. That ordering is not an optimisation I measured. It is a claim about which channel is allowed to be slow.

One notification per search, not per listing

The first version of this sent one banner per listing, because that is the shape of the data. Then a quiet Sunday produced six rooms on one search in one cycle and my screen became a stack.

/**
 * Notify about the listings found in one cycle.
 *
 * One notification per search rather than per listing: five separate banners
 * for five rooms in the same search is a notification the user turns off.
 * Clicking opens the newest listing in the user's real browser, where they are
 * already signed in.
 */
Enter fullscreen mode Exit fullscreen mode

The body shows up to three titles and the count goes in the title:

const title =
  finds.length === 1 ? `New listing on ${siteName}` : `${finds.length} new listings on ${siteName}`;
const body =
  finds.length === 1
    ? first.title || first.url
    : finds
        .slice(0, 3)
        .map((f) => f.title || f.url)
        .join('\n');
Enter fullscreen mode Exit fullscreen mode

The failure mode of a notification feature is not that it is missed. It is that it is switched off, and once it is off you have lost the channel for the one alert that mattered. Grouping is what keeps it on.

Two options on the notification are deliberate:

const notification = new bits.Notification({
  title,
  body,
  urgency: 'critical',
  timeoutType: 'never',
});
Enter fullscreen mode Exit fullscreen mode

timeoutType: 'never' keeps the banner in the notification centre instead of letting it fade after a few seconds. Someone who was in another room when the room appeared is exactly the user this feature is for.

The click leaves our app entirely

notification.on('click', () => {
  bits.shell.openExternal(first.url).catch(() => {});
});
Enter fullscreen mode Exit fullscreen mode

Not a window in our app. Not an embedded browser view. shell.openExternal, into whatever the user's default browser is, because that browser is the one holding their session on the rental site. Opening the listing anywhere else means they land on a signed out page and have to log in while the room goes to somebody else.

This is the same reasoning behind the rows in the finds list, each of which is a plain anchor with target="_blank". The app's job here is to hand the user off as fast as possible, not to keep them.

The lazy require, and why a missing banner is never an error

The module never imports Electron at the top:

function electron(): ElectronBits | null {
  try {
    // eslint-disable-next-line @typescript-eslint/no-var-requires
    return require('electron') as ElectronBits;
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Two reasons, and the second one is the one I actually care about.

The monitor can be run outside Electron. pnpm server boots the Express server and the poll loop under plain tsx, with no Electron process anywhere, which is how most development on the scraping side happens. A top level import { Notification } from 'electron' turns that into a module resolution error before any of it runs.

And a notification is a nice to have sitting in the middle of something that is not. Every path out of notifyFinds is a silent return or a logged line:

const bits = electron();
if (!bits?.Notification) return;
try {
  if (!bits.Notification.isSupported()) return;
} catch {
  return;
}
Enter fullscreen mode Exit fullscreen mode

isSupported() is wrapped because it is a main process call that can throw on a machine with no notification service, and the honest answer to "did the banner fail" is that the user has an email and a row in the finds list either way. Nothing in this file is allowed to fail a poll.

The one channel the user can turn off

There are three channels now: the desktop banner, the email, and the finds list in the window. Only one of them has a switch.

/**
 * Local alert channels. Only the desktop banner is configurable: the email is
 * the licensed part of the product and the activity log is always on.
 */
app.patch('/api/config/notifications', (req: Request, res: Response) => {
  const { desktop } = req.body ?? {};
  if (typeof desktop !== 'boolean') {
    return res.status(400).json({ error: 'desktop must be true or false' });
  }
  ...
});
Enter fullscreen mode Exit fullscreen mode

Note the check in the monitor: config.notifications?.desktop !== false. Not === true. An existing config file written before this feature existed has no notifications key at all, and the users of those configs should get banners without going and finding a setting. Default on, opt out, and the absence of the key means on.

Where to see it

The app is a free download for Windows and macOS, and the help page covers what arrives where. If you want to see the alert side of the product before installing anything, the per site pages spell out what gets watched on each rental site, for example Kamernet or SpareRoom.

The general lesson I would take out of this file: when you add a second delivery channel, the interesting decisions are not in the API you are calling. They are in what you send per event, what you do when the channel is absent, and where the click goes.

Top comments (0)