The Idea
The app started as a simple tool: enter two locations, find the geographic midpoint, search for nearby venues. Clean, useful, but fundamentally flawed β geographic halfway ignores the fact that roads, traffic, and transport options mean one person almost always travels longer than the other.
The Original Implementation
The first version was a basic Sinatra app with a few moving parts:
- Two location inputs using Google Places autocomplete (via the
google_placesgem) - A geometric midpoint computed as the average of the two coordinates
- A
GET /spotsendpoint that called the Places API with a fixed 500m radius - Results rendered as a list in the sidebar
- Google Maps JavaScript API for the map display
- jQuery + Select2 for the autocomplete dropdowns
It worked, but "worked" in the loosest sense. The midpoint was purely mathematical. Someone driving from a highway suburb and someone in a dense city grid would get the same result β a point that's geographically central but travel-time unequal.
Upgrading to a Fair Midpoint Algorithm
The approach
Instead of one midpoint, generate 13 candidate points around the geometric midpoint and evaluate each one:
- A 3Γ3 grid (9 points) using lat/lng offsets scaled to the distance between A and B
- 4 wider cardinal points (north, south, east, west) to extend the search
- Spread formula:
clamp(distance Γ 0.20, 0.005, 0.04)so the grid scales with how far apart the two people are
For each candidate, compute the travel time from Person A and Person B using the Google Routes Matrix API (computeRouteMatrix).
Scoring
Each candidate is scored:
score = fairness_gap Γ 0.75 + total_time Γ 0.25
Where fairness_gap = |timeA - timeB|. The formula prioritizes equal arrival over raw speed β a point where both travel 30 minutes beats a point where one travels 10 and the other 25, even though the latter has a lower total.
The candidate with the lowest score wins. Top 3 are returned so the frontend has fallbacks.
Fairness verdict
The travel time gap of the best candidate is classified:
- Very fair β gap under 3 minutes
- Mostly fair β 3β8 minutes apart
- Uneven β more than 8 minutes (still the best found)
I probably need to adjust the fairness verdict at some point but since I'm the developer, I just made up my own rules.
Per-Person Transport Modes
Each person independently selects Drive or Commute (transit). Travel times are computed per mode. A departure time input (defaulting to now) feeds into transit lookups so bus/train schedules are factored in.
A subtle bug I encountered
When departureTime is passed to computeRouteMatrix with DRIVE mode and routingPreference: TRAFFIC_AWARE, the Routes API requires the time to be in the future. Since the meetup time defaults to "now" and the request takes a few seconds, the time is already in the past by the time it hits the API β causing a 400 INVALID_ARGUMENT response and empty results.
Fix: only pass departureTime to the matrix for TRANSIT mode. Drive uses base travel times, which is fine for fairness comparison since we're scoring candidates relative to each other.
Server-Side API Proxying
Two Google API keys are used:
| Key | Restrictions | Used for |
|---|---|---|
API_KEY_BROWSER |
HTTP referrer | Maps JS rendering, Places detail lookups |
API_KEY_SERVER |
IP address | Routes API, Places autocomplete, nearby search |
Anything heavier goes through the server.
All Routes API calls (computeRoutes, computeRouteMatrix) are proxied via the Sinatra backend. The browser never sees the server key, so it not just sitting there waiting to be copied.
Probably overkill for a tiny app, but it keeps things clean and gives me a bit more control.
Venue Search: Auto-Escalating Radius
The original (my initial naive implementation) fixed 500m radius was replaced with an escalating search:
500m β 1000m β 1500m β 2000m β 3000m β 5000m
The search stops as soon as 5 or more venues are found. The radius is also capped at half the distance between A and B β beyond that, venues are no longer genuinely "in the middle."
The backend returns { spots: [...], radius: N } so the frontend knows the actual radius used and can size the search circle on the map accordingly.
Map UI Changes
- Removed the A-to-B route polyline β replaced with two lines: Aβmeeting point and Bβmeeting point, in different colours
- Venue results removed from the sidebar β replaced with numbered pins on the map
- Clicking a pin shows: photo, place type, price level (
$/$$/etc.), open/closed status, address, phone, website, Google Maps link - After spots are found, the map zooms to fit the search circle (centered on the meeting point, sized to the used radius)
Removing jQuery
The original app used jQuery 1.12.0 (from 2016) and Select2 for the autocomplete. Both were replaced:
- Tom Select β modern, zero-dependency Select2 replacement. Handles AJAX autocomplete, same UX
- Vanilla JS β all
$.ajax,$().addClass,$().prop,$().onetc. replaced with native DOM APIs (fetch,classList,addEventListener,disabled,textContent)
The app already used fetch and async/await for the map interactions β jQuery was only hanging around for Select2 and a handful of convenience methods.
Security Fixes
Five issues identified and fixed:
- XSS β all user-facing data (venue names, addresses, phone numbers) concatenated into HTML was wrapped in an
esc()helper that encodes& < > " ' javascript:protocol βdata.websiteis validated against/^https?:\/\//before being used as anhref- Mode parameter whitelist β
modeparameter in all POST endpoints is validated against["DRIVE", "TRANSIT"]and defaults to"DRIVE"if invalid - Debug logging removed β
warn "[MATRIX]..."lines added during debugging were cleaned up before production - jQuery CVEs β eliminated entirely by removing jQuery
Automated Deployment with GitHub Actions
The live server is my DigitalOcean droplet running Docker. Deployment is automated via a GitHub Actions workflow:
on:
push:
branches:
- main
On every push to main, the workflow SSHes into the droplet and runs:
git pull origin main
docker compose up --build -d
docker image prune -f
No Docker Hub involved (as opposed to initial manual process) β the image is built directly on the server from source. Secrets stored in GitHub.
Key Takeaways
- Geographic midpoint β fair midpoint. Roads, modes, and traffic make the real middle asymmetric.
- Google Routes Matrix API is the right tool for multi-destination travel time lookups β not the Distance Matrix API (which requires a separate enablement and has different quota).
departureTimewith DRIVE mode requires a future timestamp andTRAFFIC_AWARErouting preference. For a fairness comparison use case, omitting it for drive mode is simpler and more reliable.- Two API keys (browser-restricted + server-restricted) is the right pattern for Google Maps apps. The browser key handles rendering and Places details; the server key handles everything that shouldn't be exposed client-side.
- Removing jQuery in a small app is straightforward. The only real dependency was Select2 β once that's replaced, vanilla JS covers everything else cleanly.
- Claude is fun for weekend projects.