Have we done this before?

One of the internal tools I built at work has a capability map on its front page: things you can do down the side, places across the top, and a dot in a box for every project that did that thing there. People mostly arrive with one question, which is some version of have we done this before? So the most used thing on that page isn't the map. It's the search box on top of it.

This is that box again, with an invented town: seven neighbourhoods, eight public services, eighteen projects and the lines written about them. The matching, the ranking, the breadcrumb stack and the empty state are ported from the real code. The town is not.

doneunder wayplannedone dot, one project
Type two letters or more. Try LED, then led, then bin and night bus. / jumps to the box, Esc clears. The switch swaps in the later version.

Whole words, mostly

The first version matched substrings, which is what everyone does first and regrets. Search bin and you got the heated cabins at the station. So the query became a regular expression with a few rules. It has to start at a word boundary. Four letters or fewer must also end at one, so bin is a bin. Anything longer may be a prefix, so light finds lighting. Spaces match a space, hyphen, underscore or slash, so night bus finds the night-bus loop.

And the one I'm fondest of: an acronym of five letters or fewer, typed in capitals, is case-sensitive. LED finds lanterns. led also finds a library led by volunteers. Same letters, different question.

// Whole words. Short words and capitalised acronyms must end too;
// longer words may be a prefix. Capitals make it case-sensitive.
function qRx(q) {
  const t = q.trim();
  const e = rxq(t).replace(/\s+/g, '[\\s\\-_/]*');
  const acro = t.length <= 5 && /[A-Z]/.test(t) && t === t.toUpperCase();
  const tail = t.length <= 4 || acro ? '(?![A-Za-z0-9])' : '';
  return new RegExp('(?<![A-Za-z0-9])(' + e + ')' + tail, acro ? '' : 'i');
}

That's the whole of the cleverness. There is no stemming, no synonym table and no "did you mean". What makes it feel smarter than it is: every line of evidence carries several fields (what was done, the kit used, the project's name, the crew), and a query is tested against all of them.

Where the answer goes

The search runs 200ms after you stop typing, and only from two characters up. One letter does nothing at all, which means it also leaves the last answer standing while you backspace. Results land in the panel under the map in a fixed order: crews whose name matches, then kit you could trace across the map, then projects. Projects are ranked by one thing only, how many of their lines matched, ties left in data order. A project whose name matches but has no matching lines still shows, at the bottom.

On the map, every box holding a matching line lights up and everything else drops to 28% opacity, all at once over 0.25s. The dots of the matching projects grow by 1.35 and the rest fade. The same lighting code draws wires for other views, but search deliberately skips them: wires are for following one thing, and a result is a list.

Every view goes onto a stack, shown as breadcrumbs. A search replaces its own previous entry, so typing lam, lamp, lamps leaves one crumb, not three. The stack keeps the first entry and the last eight. Open a project from the results and its lines arrive with your query marked. Clear the box and only the search crumbs go; whatever you opened from them stays.

Not found is an answer

The empty state was the part I rewrote most. "No results" reads as "no", and here it usually means "not written down yet". So it says that, suggests a shorter word, and notes the miss without your name: if the box still holds the same words 1.8s later, the query is logged so someone knows what to fill in next. In this demo the misses live in memory and vanish when you leave.

The later version

When I rebuilt the tool in Next.js, the box on the map got smaller in ambition. It now matches a lowercase substring of the row and column names only, from two characters, with no debounce, because all it does is toggle classes on a grid the server already drew. Whole rows and columns light, and each box gets --qd = n × 0.04s so the matches ripple in over a 0.9s glow instead of blinking on. The matched part of the row label gets a <mark> built from text nodes, so nothing typed is ever parsed as HTML. Enter on a single match opens it; anything else goes to a full search page, and Escape clears the search before it clears a selection.

I think that split is right. Search on the map now answers "where", and "have we done it" got its own page.

What I added here

The grey readout under the box and the "matched in" line on each result are new for this page; the real tool never explains itself. In the later mode, Enter runs the first version's search, since there is no other page to go to. Everything else behaves as the original does.

Built
October to November 2026
Stack
Vanilla JS, then Next.js and React
This page
Vanilla JS and CSS, no libraries
Lives at
An internal tool at work
Source
Private

Built with Claude Code. The town, its projects and every line on this page are invented.