Trips - shareable multi-stop planner
I was planning a day out in Mumbai with friends and needed three things in one place: what time we were at each stop, how we were getting between them, and a way to send the whole thing to everyone. Google Maps does the route but not the schedule. A WhatsApp message does the schedule but not the map. A shared doc does neither.
So I vibe coded one in an evening: a single HTML file, no build step, no backend, no accounts.




The plan is the URL
The requirement that decided everything else was sharing. Not sharing as a feature with an invite flow, just: my friends need to open this. The only thing everyone can already open is a link.
So there is no database. The trip is the query string. Here are the first two stops of the day above, with the spaces decoded so it is readable:
?s=Gateway%20of%20India%2C%20A%20Ward|18.92196|72.83456|2026-04-04T08:00,
Hutatma%20Chowk%2C%20Fort|18.93274|72.83163|2026-04-04T08:30
&m=0:walk,1:metro,2:taxi
s is the stops, pipe separated: name, latitude, longitude, time. Each name is
percent-encoded before the stops are joined, which is why the comma inside
“Gateway of India, A Ward” does not split the list. m maps each gap between
two stops to a transport mode, keyed on the index of the stop it leaves from.
Every edit calls history.pushState, which is where undo comes from. The back
button is the undo button. That is half the reason state went into the URL and
not into memory: no undo stack to maintain, no shortcut to teach anyone, and it
works the same on a phone. Delete a stop by mistake, press back, it is there
again along with the mode that went with it.
window.addEventListener("popstate", () => {
_skipPush = true;
stateFromURL();
render();
});
The _skipPush flag stops the restore from pushing a fresh entry on top of the
one it just came from.
Reordering a day is not reordering a list
The obvious way to move a stop is the one-liner:
const [item] = waypoints.splice(from, 1);
waypoints.splice(to, 0, item);
The time sits on the stop, so it travels with it. That is correct for a list of objects and wrong for a plan of a day.
When you drag Malabar Hill above Marine Drive you are not saying “move 11:30 to Malabar Hill”. You are saying “let’s be at Malabar Hill at 09:00 instead”. The time belongs to the slot in the day, not to the place. Try it both ways:
naive splice what it does now
08:00 Gateway of India 08:00 Gateway of India
08:30 Hutatma Chowk 08:30 Hutatma Chowk
11:30 Malabar Hill 09:00 Malabar Hill
09:00 Marine Drive 11:30 Marine DriveThe naive rule produces a day that runs backwards. The fix moves only the location and writes it back into the fixed slots:
function reorderWaypoint(from, to) {
// Only move name + coordinates. Times, modes, and slot positions stay fixed.
const names = waypoints.map((w) => w.name);
const lats = waypoints.map((w) => w.lat);
const lngs = waypoints.map((w) => w.lng);
const [n] = names.splice(from, 1);
names.splice(to, 0, n);
const [la] = lats.splice(from, 1);
lats.splice(to, 0, la);
const [ln] = lngs.splice(from, 1);
lngs.splice(to, 0, ln);
waypoints.forEach((w, j) => {
w.name = names[j];
w.lat = lats[j];
w.lng = lngs[j];
});
}
Uglier than a two-line splice, and right for what the thing actually is.
What it deliberately does not do
It draws straight lines between stops, not routes. Routing means an API, a key
and a rate limit, and a plan does not need one: you want the shape of the day,
and two pins with a line between them say enough. When you actually need
directions there is a button that hands the whole trip to Google Maps as
/maps/dir/lat,lng/lat,lng/.
Search is Nominatim, which is free and needs no key. 300 ms debounce, results biased towards whatever the map is currently showing, coordinates rounded to five decimal places. Five is roughly a metre, which is more precision than a day plan needs and keeps the link shorter.
Deploying it
Push to main, GitHub Actions runs wrangler pages deploy ., done. There is no
build step because there is nothing to build. The repo is the artifact. The only
things the page fetches are Leaflet 1.9.4 and CartoDB’s map tiles, and the page
itself is about 14.5 kB gzipped.
It is vibe coded and it looks it: one long file, styles inline on elements, a
helper called _i() that returns SVG markup as strings. For something built in
an evening so a group chat could agree on a plan, that was the right trade. The
reorder is the part I actually thought about.
- trips
- webdev
- maps
- leaflet
- openstreetmap
- url-state
- cloudflare-pages