Tell it where you are in Sài Gòn, how much time you have, and your budget — get back an optimized outing: which spots, in what order, with honest travel times.
👉 Try it: pho-around.vercel.app — no account, no signup
First load may take 30–60 seconds. The backend sleeps on its free tier and has to wake up; everything after that is fast. The loading panel will tell you it's thinking.
Most "where to eat" apps are searchable lists. Phở around treats an afternoon out as an optimization problem: it picks a subset of ~100 curated places and orders them into the best route that fits your time and money — using real Operations Research, not a sorted list.
- Say what you want — "cà phê, không shopping, tối đa 3 chỗ" — on the landing page, then tell it where you are (your location, a place name, or a tap on the map). Or skip the question and set a start point, a time budget and a spending budget by hand.
- The backend solves an orienteering problem — a prize-collecting traveling-salesman variant — as a mixed-integer linear program (PuLP + CBC): maximize the total appeal of visited places, subject to your time budget (dwell + travel + a realism buffer per stop) and your money budget, with MTZ constraints keeping the route a single connected path.
- The route renders as numbered pins and a path on a custom-styled Google Map, with per-leg ride times and a running total — every stop deep-links into Google Maps for photos, hours and reviews.
Some details the optimizer gets right that a list never could:
- Travel time is real. Place-to-place motorbike times come from a precomputed Routes API
matrix (
TWO_WHEELER— this is Sài Gòn). The leg from your start point is estimated by a distance formula calibrated against all 1,988 real legs in that matrix: 12.6 km/h, the city's measured median riding speed, with a median error of 1.7 minutes. Live traffic-aware start legs are a one-flag upgrade (USE_LIVE_START_LEGS), left off so the public demo costs nothing to run. - Routes don't zigzag. Travel enters the objective as a carefully-sized tiebreaker, so among equally-appealing plans the solver always returns the shortest ordering — without ever sacrificing a better place to save a minute of riding.
- It doesn't overpromise. Every stop is charged a parking/queueing buffer, and the time budget is hard — the plan you get is one you can actually live.
- You can talk to it, without it doing the planning. A sentence goes to a language model
whose only output is a typed constraint list —
min_category coffee 2,exclude_category shopping,max_stops 3— drawn from a closed vocabulary, with categories and place ids constrained to enums so it cannot name anything that doesn't exist. The solver still decides which places and in what order. That division is the point: a model asked for a plan produces something that reads right, while the MILP proves your budget holds. Follow-ups work too — "actually make it 2 coffees" edits the constraints and re-solves, with the interpretation shown back so a misread request is obvious before the route is. - It scales politely. Each request pre-filters the dataset to 25 candidates — the 18 nearest to your start, plus the 7 best anywhere in the city — so a far-away gem always keeps its seat at the table. That number is measured, not guessed: on the deployment's modest CPU a 40-place pool sometimes failed to find any plan in time, while 25 delivers 98.7% of its own best objective under pressure.
~100 real Sài Gòn places — phở and bún bò to specialty coffee, photobooths, museums and
landmarks — hand-curated (dwell times, real VND prices, categories and tags are human
judgement), with facts (coordinates, ratings, place ids) resolved through the Geocoding and
Places APIs by dry-run-first import scripts in backend/scripts/.
The list lives in Postgres (Supabase): curation happens in its table editor, the API reads approved rows through PostgREST behind a row-level-security policy (the app holds no write credential at all), and a snapshot script writes the data back to a committed JSON — which doubles as git history for every human judgement, the offline fallback the app serves when the free-tier database naps, and the test fixture that keeps the suite off the network.
| Layer | Choice |
|---|---|
| Frontend | React + Vite, plain JavaScript, hand-written CSS design system |
| Map | Google Maps JavaScript API (custom cloud style, advanced markers) |
| Backend | FastAPI (Python) |
| Optimizer | PuLP + CBC (mixed-integer linear programming) |
| Travel times | Google Routes API (computeRouteMatrix, two-wheeler mode) |
| Data | Postgres (Supabase) via PostgREST + RLS, snapshotted to JSON; precomputed travel matrix |
| Language | Google Gemini (free tier) for POST /api/interpret — sentence → typed constraints |
| Hosting | Vercel (static frontend) + Render (uvicorn + solver) |
The split is deliberate: the frontend is static files, which a CDN serves for free from everywhere, while a request that spends seconds inside a native MILP solver is a poor fit for serverless duration caps and a fine fit for an ordinary long-lived process.
Two dev servers, two terminals. You'll need a Google Cloud project with the Maps JavaScript,
Geocoding, Routes and Places APIs enabled, and two Maps keys (see the .env.example files —
a browser key locked by referrer + API restrictions, and a server key kept server-side).
The "just say what you want" box additionally needs a Gemini API key from
AI Studio, set as GEMINI_API_KEY. Without it that one
feature returns a clean 503 and everything else works unchanged. Create it in a project with no
billing account attached — a key in a billing-enabled project can land on the paid tier and bill
per token, where a free-tier key simply stops at its daily allowance.
# backend — http://127.0.0.1:8000
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn api:app --reload
# frontend — http://localhost:5173
cd frontend
npm install
npm run devTests: python -m pytest from backend/ — 112 covering the engine, the HTTP layer, travel-time
lookup, the incremental matrix builder, rate limiting, the language boundary (with the model
mocked — no test in the gate touches the network), the database source with its fallback
chain, and the solver's behaviour pinned by
hand-checkable instances.
How well the model actually reads requests is measured separately, on demand, because it costs real quota and its answers vary between runs:
PYTHONPATH=. .venv/bin/python scripts/eval_interpret.pyTwelve cases in Vietnamese and English, built around negation — "no coffee" becoming "at least one coffee" would pass every validator and be exactly backwards.
Live since August 2026. The v1 core loop is complete and deployed: optimized routes over real data on a designed UI, reachable from any phone.
Deployment taught more than local development could. A solver timeout was silently discarding a perfectly good plan, because CBC reports "Not Solved" when the clock stops the search even though the route it's holding is feasible. A candidate pool that was comfortable on a laptop found nothing at all on a shared tenth of a CPU. And a travel-time matrix rebuilt three times during development arrived as a $106 bill — which is why the running app now makes zero API calls per click, and why every import script asks before it spends.
The language layer landed in August: the model proposes constraints, the solver still disposes. Next: accounts and a database, which bring saved itineraries — and let people suggest places worth adding.
A full-stack learning project, built end-to-end — data modeling, MILP optimization, API design, React, and a lot of respect for Sài Gòn traffic.
