Start here. This is the same eight decisions the "System map" further down documents for developers — told as the problem each one actually solved, no code required to follow along. Open "show the technical version" under any of them for the implementation.
The big picture — every request follows this path
What happens when you search for a place and hit "Route"
The app is really two pieces talking to each other: the screen on your phone (the frontend) and a small server (the backend) that knows Melbourne's transit stops, real-time PTV departures, and street-level driving directions. Nothing routing-related is figured out on the phone itself — it always asks the backend, and the backend decides what to do.
Typing a place name asks the backend to search its station index. Picking a destination asks a separate service (Nominatim) to turn a place name into map coordinates. Hitting "Route" is where the real decision-making happens: the backend walks through your stops two at a time, and for each pair decides "is this a train leg or a car leg?" — train legs go to the PTV logic below, car legs go to a self-hosted driving-directions service called OSRM. Every leg's outcome, success or failure, gets collected, and the whole trip comes back as one response the map can draw.
Frontend (Expo Go / React Native)
└─ MapExplorationScreen
├─ searchStations() → GET /api/map/stations/search → GTFS stop index
├─ lookupDestination() → GET /api/map/destination/lookup → Nominatim (place → coordinates)
└─ calculateRoute() → POST /api/map/route/calculate
│
├─ strategy "car" → osrmRoute(origin, dest) → OSRM API (driving directions)
│
└─ strategy "ptv" → pairwise loop over [origin, ...waypoints, dest]:
├─ station → station → getPTVRoute()
│ ├─ queryStreamingRaptor() [tries first, 800ms budget]
│ └─ getTripBetweenStations() [falls back if Raptor comes up empty]
└─ anything else → osrmRoute() [treated as a car leg]
│
└─ each leg: recorded as a success (RouteSegment) or a FailedLeg
└─ once every leg is tried: HTTP 200 (all worked) or HTTP 207 (some didn't)
Read top to bottom: the frontend only ever calls three functions. Everything under calculateRoute() is a decision the backend makes on your behalf, one stop-pair at a time — that decision loop is the subject of the next card.
Mar 20 – Mar 29, 2026
Teaching the app what a "stop" actually is
Melbourne's public transport data doesn't agree with itself. The same physical station shows up under different names depending on which data feed you ask — "Clayton Station" in one, "Clayton Rd Station" in another. Worse, a train station is really two different records in the data: a general entry with no trains attached to it, and a separate "platform" entry that's the one trains actually use. Early on, the app kept finding the wrong one, so it looked like routing was broken when really it was just asking about a place that, as far as the timetable was concerned, had no trains.
The fix wasn't cleverer searching — it was building one address book, once, that every part of the app reads from. Every stop gets a clean "filing name" so near-duplicates land in the same drawer, a separate polite name for what the user actually sees, and a hard rule that routing always resolves to the platform that has real trains, never the parent record that just groups platforms together.
▸ Show the technical version
Stop keys are normalized (lowercase, suffix-stripped) at storage time, with the original casing kept separately as displayName — so every downstream caller inherits one consistent key for free rather than re-normalizing inconsistently at query time.
Melbourne GTFS is two-level: location_type = 1 parent stations group platforms but carry no trips; location_type = 0 platform/child stops are the ones present in stop_times. findStopByName() does a two-pass search — trips-indexed stops first, any-name match second — so it never resolves to a tripless parent.
Stops from different feeds that normalize to different keys but sit within 150m are merged under one canonical name, since string normalization alone can't tell "Clayton" and "Clayton Rd" are the same physical place — and stripping road suffixes outright would also rename "Flinders Street Station" down to an unsearchable "flinders".
Mar 5, 2026 → generalized Mar 29, 2026
One journey can zig-zag between car and train, and that's fine
A real trip across Melbourne is rarely just "drive" or just "get the train" — it's drive to a station, train partway, maybe another train, maybe drive again at the end. The first version of the app tried to handle this with one fixed pattern: car, then train, then car, always in that order. It broke the moment anyone wanted two train legs in a row, or train-car-train.
Instead of one rigid pattern, the app now looks at each consecutive pair of stops on its own. Station-to-station becomes a train leg; anything else becomes a car leg. String together as many stops as you like, in any order — each pair decides its own leg type independently, so new combinations just work without anyone having to add a special case for them.
▸ Show the technical version
The old park-and-ride strategy was hardcoded car → PTV → car. It's replaced by a pairwise loop over consecutive waypoints:
for (let i = 0; i < allPoints.length - 1; i++) {
const from = allPoints[i], to = allPoints[i + 1];
if (from.type === "station" && to.type === "station") { /* PTV leg */ }
else { /* car leg */ }
}
Park-and-ride is now just a pattern the user creates naturally by choosing stations — not a strategy the code has to know about by name.
Formalized through Apr–May 2026
When one leg breaks, the trip doesn't have to
If you plan a four-stop trip and one leg genuinely has no route between it, the old behavior would be to reject the whole request — no map, no information, just an error. That's frustrating when three of the four legs are perfectly fine and could have been shown.
The server now returns every leg it could actually solve, and clearly flags the one it couldn't, instead of throwing the whole trip away over a single broken segment. The map draws what worked and shows a plain-language note on what didn't.
▸ Show the technical version
A dedicated FailedLeg type sits alongside RouteSegment in a discriminated union, and the endpoint reports the outcome with HTTP 207 (Multi-Status) rather than 400:
segments: (RouteSegment | FailedLeg)[]
const hasFailures = segments.some(s => s.type === "failed");
res.status(hasFailures ? 207 : 200).json(response);
"failed" belongs only to FailedLeg, never to RouteSegment — keeping the discriminant unique per type is what lets TypeScript narrow automatically without unsafe casts.
Mar 24 – Mar 25, 2026
The server that couldn't say hello
Twice, the entire app appeared to be down — every request timed out, even simple ones like searching for a station name. Both times, the actual problem had nothing to do with the network: the server was doing a huge amount of work first — loading millions of timetable rows, or searching a giant nested list of possible train transfers — before it ever got around to accepting a single connection. It's the equivalent of a receptionist who won't pick up the phone until an entire filing cabinet is done.
The fix was splitting "what must happen before we answer the phone" from "what can keep happening quietly in the background." The server now starts accepting requests immediately, and slower background work finishes on its own — with a hard cutoff so no single search can hold up the whole app indefinitely.
▸ Show the technical version
Only what's strictly required is await-ed before app.listen(); everything else loads in the background with .catch():
await loadGtfsTimetables();
await loadGtfsStops();
app.listen(PORT); // server starts HERE
loadRaptorStreaming().catch(); // background
Separately, findTransferJourney() is a triple-nested synchronous loop — and since Node is single-threaded, that loop holds the event loop exclusively while it runs. An 800ms deadline check inside it returns null early enough for the proven timetable fallback to still answer within the client's 10s timeout. async/await does not protect the event loop from synchronous CPU work sitting inside it.
Mar 27, 2026
Labeled boxes, instead of guessing what's inside
Some data in the app can be one of two different shapes — a direct trip versus one with a transfer, for instance. The early code stored both in boxes that looked identical from the outside, and just trusted that whoever opened the box already knew which one it was. That trust broke at least once, and needed an unsafe workaround to paper over it.
Every such box now carries its own label built into its type — "this is a direct trip" versus "this is a multi-leg trip." The rest of the code is required, by the compiler, to check the label before it's allowed to assume what's inside.
▸ Show the technical version
type TripResult =
| { kind: "direct"; tripId: string; stops: StopTime[] }
| { kind: "multi-leg"; legs: DirectTrip[] }
With a kind discriminant, TypeScript narrows automatically inside a switch or if — no as unknown as {...} casts required, and the caller cannot reach the wrong branch's fields without the compiler stopping them first.
Mar 27, 2026
One ruler, not four that might quietly disagree
Distance between two map points is simple math, but four different parts of the app had each grown their own copy of that calculation independently — and one of the four copies had a small, real bug in it (mixing up latitude and longitude on one line). Whoever hit that one code path got a subtly wrong answer, with no error to flag it.
One shared function now does that calculation everywhere. Fixing or improving it fixes it everywhere at once, instead of relying on someone remembering there are four separate places to check.
▸ Show the technical version
// utils/geo.ts — the one copy that survived
export function distanceMeters(coord1, coord2) { /* Haversine */ }
The bug that prompted the consolidation: const dLng = toRad(coord2.lng - coord1.lat) — .lat used where a longitude diff was required, in exactly one of the four duplicates.
Apr 23 – Apr 25, 2026
Keeping the clock honest across a multi-leg trip
For a trip with three legs, the arrival time of leg one should become the departure time assumed for leg two — otherwise you get three separate, disconnected time guesses instead of one trustworthy "you'll arrive at 5:40pm." A related bug meant the app was also picking the first train it found in the timetable rather than the soonest one — showing a 40-minute wait when the real next train was 8 minutes away. That one took two attempts: the first fix moved the bug to a different loop instead of removing it.
Each leg's calculated arrival time is now threaded forward as the next leg's assumed departure time, so the whole journey tells one coherent time story instead of three unrelated ones.
▸ Show the technical version
let currentTime = initialDeparture;
for each [from, to] pair:
const result = query(from, to, currentTime);
currentTime = result.arrivalTime; // thread into next leg
The departure-time bug itself was an early-return inside a loop: it answered "first match" when the code needed "best match." The fix replaced the early return with a best-so-far accumulator, updated whenever a smaller wait was found, and returned only once every candidate had been checked.
Apr 7, 2026
Quiet in production, loud when something's actually wrong
While building the app, there's a lot of debug chatter that's genuinely useful during development — but noisy, or in the worst case embarrassing, in a live build a stranger might inspect. At the same time, real errors still need to stay visible, because those are exactly what you'd need if something broke for a real user in production.
Ordinary debug logs now switch themselves off automatically outside development. Errors and warnings are deliberately left alone, since incident investigation depends on them.
▸ Show the technical version
const log = process.env.NODE_ENV !== "production" ? console.log : () => {};
A no-op function is a drop-in replacement for console.log, so existing call sites across nine backend files didn't need to change shape — only the one definition of log did.