← Back to projects
NAVMELB / build log
36 sessions107 days2026-02-28 → 2026-06-14

A route-planning app, built one debugging session at a time

36 sessions. 1 app that finally routes home.

NavMelb chains car, train, tram and bus legs across Melbourne's transit network. This page is the unedited build log documented through markdown notes on what shipped, what broke, why it broke, and the rule each break turned into after every session now compiled into this html file via AI-assisted development. Every card below is a real session, in order. They were mostly || where it should have said !== undefined.

14Named Bugs Fixed
Same Falsy-Zero Bug
263→11 MBGit History Rewrite
2Routing Engines Replaced
Filter by lesson type —
showing 36 of 36

Why this exists

Where the idea actually came from, before any of the sessions below.

The origin

A conversation, discussing potential problems and gaps turned into solution

The idea came out of a conversation with a friend, comparing where Apple Maps and Google Maps each fall short — lane guidance, how traffic renders, small stuff neither one gets fully right — and somewhere in that conversation the question became "why not both."

What I actually chased was narrower than that. Both apps make you commit to a mode before they'll plan anything — driving, or public transport, never mixed — even though a lot of real trips are neither. Drive to a station, train partway, maybe walk the rest. NavMelb started as an attempt to answer that one gap directly: let a route explicitly chain car and PTV legs together instead of forcing an either/or pick. (I later found out Google Maps do some auto-connecting here already, but at least not manually and continued on the project that was primarily my first time building something full-stack with AI as a collaborator.)

Because it was my first time working with AI like this, I made a point of AI-assisted documenting after each session on what worked and what didn't as I went — partly so I wouldn't repeat the same mistake twice, partly so I could actually explain afterward what happened, not just that it eventually worked. This build log is what that habit turned into.

How it works

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.

Recurring patterns

Five things that didn't happen once — they happened across weeks, to the same codebase, until they became rules.

01 · SPANS 3 SESSIONS

The Number(x) || default bug recurred at least three times (PR #4, the pr-9 contract fix, and again in the 06-11/06-14 diagnosis) — 0 is falsy in JS, so a legitimate zero silently becomes the default. House rule now: check !== undefined, never ||, for any numeric param that can validly be zero.

02 · 05-12 → 05-21

"Accept both" in a merge conflict is concatenation, not reconciliation — it broke main on 05-12 (duplicate declarations, a 5-arg call to a 3-arg function). Nine days later the same class of conflict was resolved correctly by reading what each side added and hand-writing the merged result.

03 · 04-25 → 05-31, THE BIG ONE

Migrating off GTFS/Raptor onto the live PTV API was the single largest arc of the project — six sequential bugs (HMAC signing, wrong endpoint shape, wrong response shape, mismatched stop IDs, discarded geometry, a wrong dwell-time constant) before the new engine matched the old one's behaviour.

04 · 06-11 → 06-14

"Config that looks like code but behaves like infrastructure" — an EAS build silently shipped with undefined as its API URL because .env is (correctly) gitignored, and a tsconfig.json mismatch broke the Docker build for a week before anyone tried a clean local build.

05 · 05-24, THE CHECKPOINT

One session was spent asking whether the project was still worth continuing at all — not shipping anything. The answer was yes, because it had surfaced a real architectural insight (explicit car+PTV chaining) rather than just approximating what Google Maps already does.

06 · DRAFT — 04-15 → 06-14

DRAFT: The same root confusion kept costing time — forgetting to swap IP addresses / base URLs when testing from a different location, which then bled into not being sure whether I was hitting local or production. Ties together the 04-15 CORS whitelist, 04-18 cached bundle, 06-11 EAS env var, and 06-14 redeploy sessions — confirm this framing is accurate, then write the real version.

No sessions match the selected filters.

System map

Ten decisions that came out of the sessions above and are now just how the backend works — pulled from the project's living architecture notes.