All poststypescript · nextjs

The air in Sarajevo was good, said a station 35 km away

A fallback that finds the nearest live air quality station sounds safe until the nearest one is another town. How BosniaAir ended up reporting Vareš as Sarajevo, and the one-line fix.

BosniaAir is a small air quality site I run for six cities in Bosnia and Herzegovina. It reads from the WAQI API, which has one or more stations per city. Each city in my config has a primary station, and the site shows whatever that station last reported.

Stations go quiet. A sensor loses power or the network, and its last reading sits there getting older. For a site that tells people whether to open a window, a reading that stopped hours ago is worse than no reading. So I added a fallback: if the primary station has not reported for more than 6 hours, use the nearest station that has.

It worked. Then it worked too well.

What I saw

I noticed the page for Sarajevo said the air was good. The same page has a forecast, and the forecast for Sarajevo said unhealthy. Those two numbers cannot both be right about the same place on the same day, and I knew which one I believed.

It took me a minute to find it. The Sarajevo primary station had gone silent, so the fallback kicked in, looked around the city centre and picked the nearest station that was fresh. That was a station in Vareš. It is about 35 km from Sarajevo. Vareš is a different town, and its station reported good air.

The code was doing exactly what I wrote. The panel even said which station it was reading, because I had made the response carry a source object with the station, the distance and a fallback flag. It just never occurred to me to ask how far "nearest" was allowed to be.

The code

The selection rules live in a file of pure functions, no network calls, with unit tests. The ranking function looks like this:

export function rankFallbackCandidates(
  stations: WaqiBoundsStation[],
  center: readonly [number, number],
  options: { excludeUid?: number; now?: Date; maxDistanceKm?: number } = {},
): FallbackCandidate[] {
  const { excludeUid, now = new Date(), maxDistanceKm = MAX_FALLBACK_DISTANCE_KM } = options
  return stations
    .filter(s => s.uid !== excludeUid)
    .filter(s => Number.isFinite(Number(s.aqi)) && String(s.aqi).trim() !== '')
    .filter(s => isFresh(s.station?.time, now))
    .map(s => ({
      uid: s.uid,
      name: cleanStationName(s.station?.name),
      distanceKm: distanceKm(center, [s.lat, s.lon]),
      updatedAt: new Date(Date.parse(s.station!.time!)).toISOString(),
    }))
    .filter(c => c.distanceKm <= maxDistanceKm)
    .sort((a, b) => a.distanceKm - b.distanceKm)
}

Drop the primary, drop stations without a number, drop stale ones, measure the distance, drop the ones that are too far, nearest first. The distance filter was already there. The problem was the constant it used:

/** A fallback station further away than this would describe another place, not the city. */
export const MAX_FALLBACK_DISTANCE_KM = 40

The comment above the number said what I wanted. The number under it was 40, which was generous enough that Vareš fit inside it. I had written the right idea and then picked a value that did not enforce it.

The fix

I changed 40 to 25.

That constant is also used to build the bounding box I send to WAQI when I ask for stations around a city, so the lookup got smaller together with the filter. Nothing else needed to move.

The test needed more attention than the fix. The old test had a fixture station at about 34 km, in Vareš, and asserted that it came back in the ranking. After the change it must not. If I only deleted it, the test would no longer prove that a too-far station is skipped, so I kept the Vareš fixture and changed its expectation. I also added a fresh station about 21 km away so the ranked list still has a second entry that should pass.

station(-706618, 44.162, 18.3266, '44', hoursAgo(0.3), 'Vareš (Župni ured Vareš)'), // ~34 km: past the 25 km limit
station(-1, 43.95, 18.3, '60', hoursAgo(2), 'Closer one'),                         // ~14 km, fresh
station(-2, 43.70, 18.25, '70', hoursAgo(0.5), 'Southwest'),                       // ~21 km, fresh
// ...
assert.deepEqual(ranked.map(c => c.uid), [-1, -2])

What happens when nothing is close enough

With a tighter limit there are more days when no fallback exists. I decided that was acceptable and put the behaviour in the open. If nothing near the city reports, the API serves the primary station's last values and sets source.stale to true. The panel shows the station name and highlights it when a fallback or old data is in use.

An honest "this is old" beats a confident "this is good" from somewhere else.

The lesson

A fallback needs a boundary that is defined by what the data means, not by what is available. "Nearest live station" has no idea whether it is still describing your city. Something has to say where the city ends, and that something should be a number you can read in a test and argue with.

Two smaller things came out of it. Make the response say which source it used, because that is how I spotted this. And when you tighten a limit, rewrite the test that covered the old behaviour so it covers the new one, instead of just letting it go red and deleting it.