Aug 2026 • 6 min read

What counts as late is a setting

A live bus map that flagged 377 incidents on a Friday afternoon. The fix wasn't a better algorithm; it was admitting that every alert rule is an opinion, and deciding whose.

Context

Bus View is a live map of Sydney's buses built for someone whose job is to notice what is going wrong. The idea I was testing was simple: don't show 1,700 dots and let the person hunt, show the problems.

So the first version did the obvious thing. A bus more than two minutes behind schedule is late. Late buses on the same route in the same direction are one incident. List the incidents, worst first.

At three o'clock on a Friday afternoon it listed 377 of them.

What I started noticing

The list was technically right and useless. Sydney has around 700 bus routes running at once, and at any moment most of them have at least one bus a couple of minutes behind. A list of 377 items is not a list of problems; it's the network.

I tried the reflex fix, tightening the threshold, and watched the number fall to 295. Still not a list anyone would read. A single bus stuck at a red light shouldn't be an incident, but a single bus 25 minutes late probably should. Five buses each three minutes late on the same road almost certainly are, because that's a pattern, not noise. School routes are late in a rhythm of their own and shouldn't crowd out the rest.

The number wasn't the problem. The problem was that I had one rule where the job needed several, and I was making all of them up.

The approach that worked better

The first decision was to stop inventing the threshold. Transport for NSW publishes its own on-time-running measure: a bus is on time if it's no more than five minutes late. Using the operator's definition means the app and the person reading it agree on what the word means. Two minutes stays as a colour on the map ("a bit behind"); five minutes is where an incident can start.

The second decision was that the count matters as much as the delay. An incident gets a tier, and the rule for the tier reads like something a controller would say out loud:

function tierFor(kind, count, worstSec, school): "major" | "minor" {
  const worstMin = worstSec / 60;
  if (kind === "cancelled")
    return count >= (school ? 5 : 3) ? "major" : "minor";
  if (school) return count >= 3 && worstMin >= 15 ? "major" : "minor";
  if (count >= 5) return "major"; // a pattern
  if (count >= 3 && worstMin >= 10) return "major"; // a pattern that's biting
  if (count >= 2 && worstMin >= 15) return "major";
  if (worstMin >= 25) return "major"; // one bus, but badly stuck
  return "minor";
}

Major incidents are the short list at the top. Minor ones fold into a watch list that's there if you want it. Ranking within a tier uses the worst delay scaled by the log of the count, so a route with many moderately late buses can outrank a route with one very late one, but not by much.

Cancellations got their own class above delays. A cancelled trip has no bus on the road, so a dots-only map can never show it, and yet it's the thing a controller most needs to act on. They're grouped per route and ranked as if each cancelled trip were fifteen minutes late.

With those rules the same afternoon became a short list of major incidents, the cancellations above them, and a folded watch list. That is a list a person can read in ten seconds.

Why this mattered

  • The interesting design work wasn't on the map at all. It was in a fifteen-line function that encodes what "worth looking at" means.
  • Every line of that function is an opinion. Why five buses and not four? Why fifteen minutes for school routes? I had reasons, but they were mine, and I have never sat in a control room.

That last point led to the third decision, which is the title of this note. The incident threshold moved out of the code and into the settings panel, adjustable from two minutes to fifteen. Not because users love settings, but because the honest position is that a controller should own this number and I should own a sensible default.

Trade-offs

  • A configurable threshold means two people looking at the same network can see different incident lists. For one controller that's fine; for a team it needs to be a shared setting, not a personal one.
  • The tier rules are hand-tuned against a few afternoons of data. They will be wrong for a wet Monday morning, and there is no feedback loop yet to say how wrong.
  • Log-scaled ranking is opaque. When a route outranks another, the reason isn't visible in the UI, and someone will eventually ask.
  • Hiding minor incidents means hiding the early signs of a major one. The trend arrow on each incident (getting worse, getting better) is a partial answer; it isn't the whole one.

What I would keep doing

Take the definition of "late" from the people who run the service instead of picking a number that feels right. Write alert rules as a readable function rather than a weighted score, so anyone can argue with a specific line. Make count part of every rule, because one late bus is weather and five is a road. And when a threshold is really someone else's decision, put it in front of them as a setting and spend the effort on a good default, then go and find that person, because tuning it properly is a conversation, not a commit.