Twirl Travel in 106 minutes: where the time actually went
Three friends scattered across three time zones want to take a long weekend together. Where do they go? The default move is somebody picks Vegas, somebody else gripes, you eventually book Nashville at the worst possible moment for everyone's flights.
Twirl Travel solves the optimization problem instead: every traveler enters their own departure city and date window; the planner returns destinations ranked by total group flight cost, with fairness as a tiebreaker. Tagline I've been kicking around — who you travel with matters, where you go isn't important. Bachelor/bachelorette parties, family reunions, company retreats, scattered-around-the-country friend groups. Anyone who has ever spent two weeks in a group chat arguing about Vegas vs. Nashville.
I started the repo at 11:27 this morning. The feature-complete commit landed at 13:13. That's an hour and forty-six minutes — scaffold, schema, ~106k seeded price rows, planner, interest matching, frontend, rate limiting, security headers — from empty directory to a functioning UI.
The interesting part of that number isn't the speed. The interesting part is which minutes were expensive.
What Claude did, and what I did
Claude Code wrote the code. All of it. Every line of trip_routes.py, every migration, every CSS rule, every test stub. If you measure productivity in lines per minute, the multiplier is absurd — five-figure diffs land in seconds.
What Claude did not do: decide what to build. Decide what to cut. Notice when a clever-sounding idea was actually wrong. Pick the secondary sort key that makes the planner fair instead of cheap. Decide that a third-party API I didn't have credentials for shouldn't block the MVP.
Every decision below is a place where the first plausible plan would have produced something worse. Most of the wall-clock time on this build was me reading proposals and saying no, do it differently. That loop — propose, read, push back, accept — is the work.
Decision 1: I killed the clever airport-grouping algorithm
The problem: someone enters "New York" — should the planner search flights from JFK only, or JFK + LGA + EWR? Same for LA, the Bay Area, DC, London. If you only search the city's primary airport, you miss obvious cheaper itineraries.
The first plan was a Haversine union-find: cluster every airport within 80km of every other airport, transitively. Elegant. Two airports in the same metro are usually within 80km, so they cluster automatically. No hardcoding.
In practice: chained 80km hops produce nonsense. Trenton is within 80km of Philadelphia. Philadelphia is within 80km of central New Jersey. Central New Jersey is within 80km of Newark. Now somebody in Trenton is bucketed with EWR/JFK/LGA via two intermediate hops and is getting flight options out of New York City when they live closer to PHL.
I scrapped it. Replaced it with an override list:
NYC → JFK, LGA, EWR
LA → LAX, BUR, LGB
SF Bay → SFO, OAK, SJC
DC → IAD, DCA, BWI
Chicago → ORD, MDW
Boston → BOS, MHT, PVD
London → LHR, LGW, STN, LTN
Paris → CDG, ORY
Tokyo → HND, NRT
Seoul → ICN, GMP
Hardcoded. Boring. Correct.
The lesson isn't "Haversine bad" — it's that distance-based clustering only works when the underlying graph doesn't have transitive surprises. For metro airport groupings, a curated list captures human intent (what counts as "the same metro") that Euclidean distance can't.
Claude proposed the union-find. I read it, recognized the failure mode, and asked for the override list instead.
Decision 2: I faked the flight prices on purpose
The MVP needs flight prices. The plan was Amadeus's free-tier API. Amadeus requires a sandbox account, an API key, and OAuth credential exchange. Setting that up was going to eat 30+ minutes — possibly more if the sandbox tier returned dummy data anyway, which it sometimes does.
So: defer it. I built a synthetic median-price matrix instead. Distance-based base fare with seasonal multipliers, seeded into Postgres at startup. ~106k rows covering every origin/destination pair the MVP needs.
The shape of the contract is what matters:
flight_prices (
origin TEXT,
destination TEXT,
depart_date DATE,
return_date DATE,
price_cents INTEGER,
source TEXT, -- 'synthetic' for now, 'amadeus' later
fetched_at TIMESTAMPTZ
)
The source column is the load-bearing detail. When Amadeus integration lands, the cache lookup logic doesn't change — rows just have source = 'amadeus' and a real TTL. The provider class is stubbed; the seam is wired.
Shipping a planner with plausible-but-fake prices beats shipping nothing while I read Amadeus docs.
Decision 3: Semaphore(1) on the flight cache fetcher
flight_cache.fetch_many runs price lookups concurrently. The natural shape is asyncio.gather over N destinations.
asyncpg won't let you. A single connection can't multiplex queries across coroutines — you get InterfaceError: cannot perform operation: another operation is in progress. The fix is either acquiring from a pool per coroutine, or serializing on the connection.
The current code is Semaphore(1). Concurrency = 1.
The reason it's not 5 (which is what you'd actually want) is that the only live "provider" right now is the synthetic table — which uses the same connection that's already held by the request handler. The moment a real provider lands and acquires its own connection from the pool, the semaphore raises to 5.
I left a comment in the code explaining all of this. Future-me, or whoever else reads it, doesn't need to relitigate why concurrency is 1. The constraint is in the connection model, not the algorithm.
Decision 4: Cost optimization minimizes the group, fairness is the tiebreaker
The naive ranking: sort destinations by sum of all travelers' fares, ascending. Cheapest group total wins.
The failure mode: four travelers. Three on the East Coast, one in Anchorage. A destination in Florida might be $200 each for the three Easterners and $1500 for the Alaskan — total $2100. A destination in the Midwest might be $300 each for the three Easterners and $700 for the Alaskan — total $1600.
Florida is "cheaper" by group sum. Florida also dumps a $1500 ticket on one person while everyone else gets cheap flights. That's not what the planner is for.
The fix: secondary sort by max individual fare, ascending. The Midwest option wins despite a higher per-Easterner fare, because the worst-affected traveler pays less.
Plus: filter out any destination where at least one traveler has no available route. Ranking impossible options is just noise.
This is one of those decisions that's obvious in retrospect and easy to miss if you're moving fast. I caught it because I was reading the proposed ranking logic and thought about who the planner is for: groups where one member is geographically isolated. That's exactly the case where naive sum-of-fares produces unfair recommendations.
Decision 5: Interest matching with a configurable weight
Travelers can optionally select interests — hiking, beaches, music, museums, food categories. When they do, destinations get a blended score:
score = cost_rank * (1 - INTEREST_WEIGHT) + interest_match * INTEREST_WEIGHT
INTEREST_WEIGHT defaults to 0.3. The default leans cost-heavy because the whole product premise is we're optimizing on tangible cost when the destination doesn't matter. Interests are a tiebreaker for "given two similarly-priced options, which one has more stuff you'd actually do."
Activity suggestions come from Google Places. The integration is live. Without GOOGLE_PLACES_KEY set, the path returns [] instead of erroring — so local dev and the production deploy-without-a-key state both work. You see ranked destinations; you just don't see "here are 3 hiking trails near Asheville" until the key is configured.
Decision 6: No frontend framework
Static HTML, vanilla JS, CSS in style.css. No React, no Vue, no build step.
Reasoning, in order:
- The form has maybe 8 stateful pieces — traveler rows, date windows, preset buttons, interest checkboxes. That's not framework territory.
- Shareable URLs via location hash (
#travelers=...&interests=...) work without auth or backend persistence. - nginx serves the directory directly. No bundler, no deploy pipeline for the frontend beyond
scp. - I've shipped this exact stack before — cloudista.org is the same setup. Known shape, known operational characteristics.
The whole frontend is around 500 lines including CSS. A framework would be more code, not less.
Preset buttons cover the two cases that matter: Weekend Warrior (Friday evening departure, Sunday return) and 3-Day Weekend (Friday → Monday). Custom date windows for everything else.
Decision 7: The polish that's actually load-bearing
The unglamorous part. None of these are interesting individually; collectively they're the difference between "demo" and "MVP".
- Rate limits.
/api/trip/planis 10/min per IP. That's the expensive endpoint — it triggers concurrent flight lookups./api/airports/searchis 60/min per IP — cheap autocomplete, higher ceiling. - Error states. Unknown city → 400 with the field name. No travelers have routes to any destination → 422 with which travelers and why. Live provider unavailable → 503. Pydantic validation → 422 (FastAPI default).
- Security headers. Set in
infra/nginx-twirl.conf: CSP (script-src 'self', no inline), X-Frame-Options DENY, Referrer-Policy strict-origin-when-cross-origin, Permissions-Policy locking down camera/microphone/geolocation.
This is the kind of thing that's tedious to enumerate and easy to skip on an MVP. The reason to do it now: rate limits and CSP are real bugs to retrofit later — production traffic will find every missing one before you do.
What's not done
- Domain registration + SSL via Certbot. Runbook is written. Pending a final domain decision.
- Cloudflare in front. nginx is configured for real-IP forwarding via
set_real_ip_from. Pending zone setup. - First production deploy. EC2 prereqs documented, GitHub Actions deploy job is wired. Pending secrets + domain.
- Live flight provider. Amadeus stub is in place; swap-in is mechanical when I'm ready.
- Weather. The historical-averages table is seeded but not yet surfaced in the API response.
That's a deliberate stopping point. The runbooks for the deploy steps exist; the work to do them is mechanical. The expensive judgment calls — what to build, how to rank, where the abstraction seams go — are done.
The frame
The build felt nothing like "AI is going to replace programmers."
What it felt like: a new friend and I have been kicking this idea around for a week or so. I sat down at 11:27 Monday morning with an empty directory, and 106 minutes later I had a working group-travel planner with a real database, real tests, real CI, real rate limits, real security headers, and a real frontend. Claude did the typing. I did the deciding.
Every decision in this post is a place where the first proposed solution was wrong, or right but for non-obvious reasons. The Haversine union-find sounded great until I imagined the failure mode. The cost optimizer sorted by sum-of-fares until I imagined the four-person trip with one Alaskan. The flight provider was going to block the MVP for an hour until I decided to fake it.
The tooling pulls the typing cost toward zero. The judgment cost stays exactly where it was. Knowing what to build is still the hard part — and it gets more important, not less, because cheap typing makes it easier to build the wrong thing fast.
I'll write a follow-up after the live Amadeus integration lands, or after the production deploy goes out — whichever happens first.