Family trips die in two places: a group chat where the good idea scrolls away by Tuesday, and forty browser tabs nobody ever closes.
The tools don’t help, because almost all of them start at the wrong end. Booking sites want a destination and dates. Itinerary apps want a confirmation number. Both assume the hard part is already done. But the hard part — the part that actually determines whether you go anywhere — is the six months where a trip is just a sentence somebody said at dinner.
So trips. models that sentence as a real thing. A trip has four states: idea, planned, booked, past. Everything else in the app follows from taking that seriously.
The idea box has to take ideas
The first version got this exactly wrong, and it’s the most instructive mistake in the project.
Creating a trip meant entering a destination and dates, then waiting about forty-five seconds while a model built a full itinerary. That’s a reasonable flow for a trip you’ve decided on. It’s a terrible flow for a thought. Nobody waits forty-five seconds to write down “maybe Portugal in the fall?” — they just don’t write it down.
Worse: the destination field validated against the catalog of places we can actually book through. Type “someday: Tokyo” and it returned a 400. The app was rejecting ideas for the crime of being ideas.
Now creation is instant capture, dates optional end to end, arbitrary text accepted. An idea saves verbatim. “Build the itinerary” is a separate, deliberate action that promotes it. The forty-five seconds still exist — they just happen when you’ve decided something, not when you’ve thought of something.
The general lesson: if capture costs more than the thought is worth, you get no data. Every feature downstream of capture is worthless if the front door has a turnstile.
The nightly sweep, and not scraping
The planning side needs to know what’s actually available. We belong to a membership travel club with a fixed catalog of destinations, and its availability changes constantly.
My first instinct was browser automation, because that’s what you reach for when a site doesn’t advertise an API. That instinct was wrong and expensive to indulge — the availability endpoint turned out to be a plain, batchable GraphQL API. Regular HTTP requests, no browser, no headless Chrome, no profile contention.
A scheduled job sweeps all 117 destinations, today through +300 days, in about eight minutes, into a local SQLite file. A read-only service exposes it. The note I wrote to myself afterward is blunt: never browser-scrape it.
The lesson is worth generalizing. Before you automate a browser, open the network tab. A surprising share of “no API” sites are one XHR away from a clean one, and the difference is eight minutes of GraphQL versus an hour of flaky Selenium and a Chrome profile that can’t run two of itself at once.
Browsing never touches a language model
The Discover view is where you go to ask “where could we actually go over spring break?” It has three layers, and only one of them is expensive.
The scored grid is fully deterministic and unit-tested: a fit score computed from vibe-versus-season, flight difficulty from home, and how good a run of consecutive nights you can get. No model involved. Filterable by travel window and vibe, instantly.
The departures board — curated top picks per upcoming school break, each with a reason — is generated by exactly one model call, triggered after a fresh sweep lands, then cached. Browsing the board a hundred times costs nothing. And if that call fails, the last good board stays up rather than the page going blank.
The ask box is the only interactive model call: one question, one shot, three picks with concrete dates.
That layering is deliberate. An LLM in the render path means every page view is slow, costs money, and can fail. Pushing generation to the edge of the system — once per data refresh, cached, with a last-good fallback — keeps the interactive surface fast and deterministic. It’s the same shape as caching an expensive query, except the expensive query is a model.
Don’t let the model invent your primary keys
The picks that come back from a model get validated against the candidate list they were generated from. Invented destination ids are dropped. Display names are copied from our own data rather than trusted from the response. Strings are length-capped.
And when you tap “Plan this,” the app threads the exact destination id through — it does not re-resolve the name. Otherwise a pick called “Rome” gets looked up fresh and lands on a different property with a similar name, and the trip you’re now planning isn’t the one you chose.
Treat model output as untrusted input crossing a boundary. Not because it’s malicious, but because it’s confidently approximate, and an approximate foreign key is a bug that looks like a feature until someone books the wrong thing.
The worst bug: derived state clobbering user intent
Here’s the one I think about most.
You mark a trip booked. Somewhere in the background, a rebuild that started before you tapped is still running its forty-five seconds. It finishes, writes its result — and your trip is back to planned. Silently. You just look later and find the app has un-booked your vacation.
The cause is ordinary: the build wrote the whole record it had loaded at start, including the status field it had no business touching.
The fix is a rule I’d now apply anywhere a slow job writes back to a record a human can edit. A rebuild re-reads current state before writing, and may only ever promote idea → planned. It cannot demote. It cannot touch a status a human set. Anything derived writes only to derived fields.
The same principle shows up twice more in this app. Trip notes — flight numbers before, who-came-and-what-we-loved after — live in a separate column specifically so a build can never overwrite them. And an empty or garbled build now writes nothing, because it used to be possible for one failed rebuild to replace a perfectly good itinerary with an empty object, with no undo. Rebuild is refused outright on past trips: a 2010 cruise should never be one tap away from becoming a hallucinated plan generated from today’s availability.
Two tiers of taste: reject versus rank
The itinerary builder reads a taste document, and the modeling idea in it is one I keep reusing.
Preferences split into two tiers that behave completely differently. Hard constraints reject — no buffet-style resorts, no bus tours or big group transport, nothing that requires a tent. A candidate violating one of these is out, no matter how well it scores otherwise. Strong preferences rank — nonstop flights, warm weather and water, not over-scheduled. These sort the survivors.
Collapse those into a single weighted list and you get results that are technically acceptable and actually wrong: a trip that scores beautifully on six preferences while quietly violating the one rule that matters. Rejection is not just a very heavy weight. It’s a different operation.
There’s a third tier I like even more: a short checklist of things every itinerary must contain — one hands-on class, one golf touchpoint, one memorable adventure, one outdoor photographable moment. Not preferences at all. A completeness contract. A plan that misses one isn’t lower-scoring, it’s unfinished.
The document itself lives in my notes, and how apps read taste from markdown is its own post — including the admission that this particular app keeps a hand-copied version instead of fetching the live one.
Small things that matter more than they should
The freeze that reported itself as fresh. Sweep freshness was computed as the minimum timestamp across all rows. One stale ghost destination — no longer in the catalog, never updated again — pinned that minimum forever, so the board would report “fresh” until the end of time while serving month-old data. Scoping the check to current catalog ids fixed it. Aggregate health metrics that include rows you’ve stopped maintaining will lie to you, and they’ll lie in the reassuring direction.
Sharing has to survive leaving the network. These apps only exist on my private network, which is useless for sending a plan to grandparents. So the share action generates a standalone offline HTML file — fonts and all — and sends the file itself through the native share sheet. Not a link. A link would be a 404 for everyone I’d want to send it to.
Offline is the actual use case. You reference an itinerary in an airport and a hotel with bad wifi, which is precisely where a web app normally dies. Trip data is cached network-first, so the plan opens regardless. Mid-trip, the detail view switches to “Day 3 of 8” and highlights today.
Payloads matter on hotel wifi. An audit pass found the render-blocking CSS bundle was 966 KB uncompressed, largely because two dozen font faces had been inlined as base64. Moving them to real font files the browser fetches on demand took it to 6 KB gzipped. The offline share export went from 956 KB to 313 KB.
What I left undone
Push notifications are stubbed but off — the plan is exactly three per trip (a week out, the day before, the morning of), and anything more would be an app that nags. Cover photos need a real upload path. And delete is still the only way out; a soft archive is one migration away whenever it annoys me enough.
trips. is one of nine small apps I run for the family, and the shared machinery behind them is its own story. But this one taught me the thing I’d carry to any of them: model the state a thing is actually in, including the states that aren’t decisions yet. Most software only wants to know about you once you’ve made up your mind.