Family trips die in two places. The first place is a group chat, where the good idea scrolls away by Tuesday. The second place is forty browser tabs that nobody closes.
The tools do not help. Almost all of them start at the wrong end. Booking sites want a destination and dates. Itinerary apps want a confirmation number. Both assume that the hard part is complete.
The hard part is different. It is the six months where a trip is only a sentence somebody said at dinner. That part decides whether you go anywhere.

So trips. models that sentence as a real object. A trip has four states: idea, planned, booked, past. The rest of the app follows from that model.
The idea box must accept ideas
The first version got this wrong. It is the most instructive mistake in the project.
To create a trip, you entered a destination and dates. Then a model built a full itinerary. The build took approximately forty-five seconds. That flow is correct for a decided trip. It is a bad flow for a thought. Nobody waits forty-five seconds to record “maybe Portugal in the fall?” They do not record it.
There was a worse problem. The destination field accepted only places from our booking catalog. If you typed “someday: Tokyo”, the app returned a 400 error. The app rejected ideas for the crime of being ideas.
Now creation is instant capture. Dates are optional everywhere. The field accepts any text. An idea saves without changes. “Build the itinerary” is a separate, deliberate action that promotes the idea. The forty-five seconds still exist, but they occur after a decision, not after a thought.
The general lesson: if capture costs more than the thought is worth, you get no data. Every feature after capture is useless if the front door blocks the thought.
The nightly sweep, and why I do not scrape
The planner must know what is available. We belong to a membership travel club with a fixed catalog of destinations. The availability changes all the time.
My first idea was browser automation. That is the usual tool when a site shows no API. The idea was wrong and expensive to follow. The availability endpoint is a plain GraphQL API that accepts batch requests. It needs plain HTTP requests: no browser, no headless Chrome, no profile conflicts.
A scheduled job sweeps all 117 destinations, from today to +300 days, in approximately eight minutes. The results go into a local SQLite file. A read-only service serves the data. My note to myself is direct: never browser-scrape it.
The lesson applies widely. Before you automate a browser, open the network tab. Many “no API” sites hide a clean API one XHR away. The difference is eight minutes of GraphQL against an hour of unstable Selenium. It is also the difference of a Chrome profile that cannot run two copies of itself.
The browse pages never call a language model
The Discover view answers one question: where could we go over spring break? It has three layers. Only one layer is expensive.
The scored grid is deterministic and unit-tested. It computes a fit score from the vibe against the season, the flight difficulty from home, and the length of available consecutive nights. No model is involved. You can filter by travel window and vibe. The result is instant.
The departures board shows top picks for each school break, each with a reason. Exactly one model call generates it after each fresh sweep, and the result is cached. You can browse the board a hundred times at no cost. If the call fails, the last good board stays up. The page does not go blank.
The ask box is the only interactive model call. One question, one attempt, three picks with concrete dates.
The layers are deliberate. An LLM in the render path makes every page view slow and expensive, and lets it fail. I push generation to the edge of the system: once per data refresh, cached, with a last-good fallback. The interactive surface stays fast and deterministic. The shape is the same as a cached expensive query. Here, the expensive query is a model.
Do not let the model invent your primary keys
The app validates the picks from a model against the candidate list that produced them. Invented destination ids are dropped. Display names come from our own data, not from the response. Strings have a length limit.
When you tap “Plan this,” the app sends the exact destination id. It does not resolve the name again. A fresh lookup of a pick named “Rome” can land on a different property with a similar name. Then the trip you plan is not the trip you chose.
Treat model output as untrusted input at a boundary. The model is not malicious. It is confidently approximate. An approximate foreign key is a bug that looks like a feature until somebody books the wrong thing.
The worst bug: derived state overwrote user intent
This is the bug I think about most.
You mark a trip booked. A rebuild started before your tap, and it still runs its forty-five seconds in the background. It finishes and writes its result. Your trip is back to planned. There is no message. You look later and find that the app un-booked your vacation.
The cause is ordinary. The build wrote the full record that it loaded at the start. That record included the status field. The build had no business with that field.
The fix is a rule. I now apply it wherever a slow job writes to a record that a person can edit. A rebuild reads the current state again before it writes. It can only promote idea → planned. It cannot demote. It cannot touch a status that a person set. Derived data writes only to derived fields.
The same principle appears twice more in this app. Trip notes hold flight numbers before the trip, and who came and what we loved after it. The notes live in a separate column, so a build can never overwrite them.
An empty or damaged build now writes nothing. Before, one failed rebuild could replace a good itinerary with an empty object, with no undo. The app also refuses to rebuild a past trip. A 2010 cruise must never be one tap away from an invented plan built from today’s availability.
Two tiers of taste: reject and rank
The itinerary builder reads a taste document. The modeling idea in it is one I keep reusing.
Preferences split into two tiers with different behavior. Hard constraints reject: no buffet-style resorts, no bus tours or big group transport, nothing that requires a tent. A candidate that violates one of these is out. Its other scores do not matter. Strong preferences rank: nonstop flights, warm weather and water, a schedule that is not full. These sort the survivors.
A single weighted list that merges the tiers gives bad results. The results are acceptable in theory and wrong in fact. A trip can score well on six preferences and quietly violate the one rule that matters. Rejection is not a very heavy weight. It is a different operation.
There is a third tier that I like more. It is a short checklist of items that every itinerary must contain: one hands-on class, one golf touchpoint, one memorable adventure, one outdoor photographable moment. These are not preferences. They are a completeness contract. A plan that misses one is not a lower-scoring plan. It is an unfinished plan.
The document lives in my notes. How my apps read taste from markdown is its own post. That post admits that this app keeps a hand-copied version instead of the live one.
Small items that matter more than they should
The freeze that reported itself as fresh. The sweep freshness was the minimum timestamp across all rows. One stale ghost destination pinned that minimum forever. The destination left the catalog and was never updated again. So the board reported “fresh” until the end of time and served month-old data. I scoped the check to current catalog ids. A health metric that includes abandoned rows will lie to you, and it will lie in the reassuring direction.
A share must survive outside the network. These apps exist only on my private network. That is useless when I send a plan to grandparents. So the share action builds one standalone offline HTML file, fonts included. The app sends the file itself through the native share sheet, not a link. A link would be a 404 for every person I want to reach.
Offline is the real use case. You read an itinerary in an airport or a hotel with bad wifi. That is where a web app normally dies. Hotel wifi is a lie detector for web apps. Trip data is cached network-first, so the plan opens in all conditions. During the trip, the detail view shows “Day 3 of 8” and highlights today.
Payload size matters on hotel wifi. An audit found a render-blocking CSS bundle of 966 KB uncompressed. The cause: two dozen font faces embedded as base64. The fonts weighed more than the plan. I moved them to real font files that the browser fetches on demand. The bundle went to 6 KB gzipped. The offline share export went from 956 KB to 313 KB.
The work I did not do
Push notifications are stubbed and off. The plan is exactly three per trip: one a week out, one the day before, one that morning. More than that is an app that nags. Cover photos need a real upload path. Delete is still the only exit. A soft archive is one migration away, and I will build it when it annoys me enough.
trips. is one of the small apps the family uses. The part I care about most is still the first state: an idea with no dates and no promise attached.
Some of those dinner-table sentences become trips. Some stay sentences. The app does not punish either outcome. It gives the idea somewhere to wait until the family makes up its mind.