Guidesty, a group travel planner
A trip planner a group edits together: an itinerary board on a map, polls, a shared budget, lists and a chat, with an AI that drafts the days. Still being built.
Planning a trip with friends is mostly a group chat. Somebody pastes a link, somebody else asks what day that was for, a poll about the boat tour gets buried under forty messages about the flight, and by the end one person has quietly become the secretary with a spreadsheet nobody else opens. Guidesty is that spreadsheet turned into a thing the whole group can hold at once: one person starts a trip, sends a link, and everyone on it edits the same plan. A board of days on a map, polls to settle the arguments, a budget that works out who owes whom, packing and to-do lists, a chat on each stop, and an AI that drafts the itinerary from a sentence and re-plans a day when the group changes its mind. There are three of us on it, Mohamed, Ben and me, and it is not finished; this is written from the middle.
Nobody signs up first
The rule that shaped the data model is that nobody should have to make an account to start. A first visit creates an anonymous user and a session token, kept in localStorage for 90 days, and that token is an argument to every function the app calls. The invite link is the trip's own token; opening it asks for a name and nothing else, and the name is enough to be on the trip. Membership is access: a row in `tripMembers` is what lets you read a trip, and every trip-scoped function checks it first, with a rank of viewer, editor, owner, so a link you forward to someone gives them exactly what you meant to give them.
The token is ours rather than a library's because Convex's own auth package has no Svelte support, and I was not going to bolt React onto the project for a login form. Turning a guest into an account patches the same user row in place, so the trips and memberships come along; sign-up is a six-digit code sent through Resend and stored hashed, and the account only exists once the code is confirmed. The cost of starting without an account is that a guest can lose a trip by clearing a browser, so the workspace shows a warning bar as soon as a trip has any content, and the browser asks before the tab closes.
One board, everyone in it
The app is a SvelteKit single-page app over Convex, which is the database and the application server in one. There is no API of our own; every screen is a reactive query, so when one person adds a canal tour, everyone else sees it appear without a refresh, and the same query keeps the map and the board in step. That is the property the first end-to-end test checks: two browser contexts, an invite link, a poll voted on from the other side, an expense that shows €50.00 owed. A presence heartbeat every fifteen seconds shows who else is looking.
Reordering is where a shared board usually falls over, because two people dragging at once should not renumber each other's day. Each item carries an `order` string from fractional indexing and the index on the table is `[tripId, dayIndex, order]`, so a day reads out sorted and a drag writes exactly one row, between whichever two neighbours it landed on, and never touches the rest. Dragging is by handle only. The first version dragged on any touch, which on a phone meant scrolling the board picked up a card; the fix needed the handle to listen in the capture phase, because Svelte 5 delegates events too late to arm the drop zone, and it is the reason the board can be scrolled with a thumb.
Polls that change the plan
A poll is a question with two or more options, and an option can point at an item on the board. That link is what makes a vote do something: when the poll is resolved, the losing options' items are named as vetoed and the winner as the anchor, and one button hands that sentence to the AI as a re-plan of that day. The AI replaces the whole day rather than merging into it, which is what makes the result honest: the rejected stop is gone because the day was rewritten around the one the group chose.
Generation runs as a Convex action behind the Vercel AI Gateway, for two reasons. The keys live in the Convex environment and never in the build, and the gateway makes the model a configuration value: `AI_MODELS` is a comma-separated list tried in order, and the first one that returns an object matching the zod schema wins, so an outage at one provider costs a slower answer rather than a blank board. A dialog lets you pick a model by hand, in which case there is no fallback, so a comparison is a fair one. The trade was streaming: items do not type themselves in, they arrive all at once when the reactive query re-runs. Every run is logged with its prompt, model, scope and token counts.
One generation, from the dialog to the board. The current items go into the prompt, so a re-plan of day three knows what days two and four already hold.
Money that adds up
The budget is the part of a group trip that sours friendships, so it has to reconcile to the cent. Every expense is stored in integer cents and split evenly among whoever it names, with the remainder cents going to the earliest participants, so three people splitting €100 are charged 33.34, 33.33 and 33.33 and the total is still €100. Settlement is greedy: the largest debtor pays the largest creditor until everyone is at zero, which for a group of six comes out as a handful of transfers instead of a matrix of who paid what for whom. The lists are the same idea at a smaller scale; a packing item typed twice by two people is one item, because the list dedupes on text.
The revenue side is affiliate links, and the interesting part is a redirect. Booking.com pays through the CJ network, and a plain booking.com link is not attributed, so every stay link goes to `/go/booking` with a destination, dates and guest count, and that route answers a 302 to the CJ click URL, which lands on the Booking search. The route only ever builds a Booking search from its parameters, so it cannot be turned into an open redirect, and it sends `Referrer-Policy: no-referrer` so the trip id it carries for attribution does not leak to the next page. GetYourGuide is simpler: the model emits a search phrase and a confidence per item, and items above 0.5 get a button.
Guides a crawler can read
The app runs with server rendering off everywhere, because sessions live in localStorage and Convex is the data layer, and that is fine for a board nobody reaches without a link. It is not fine for the guides, which are how people are meant to find the site, since a page that only exists after JavaScript runs is invisible to a crawler. So the guides are not Svelte pages: they are server routes that return finished HTML strings, shell and article, with the JSON-LD and a dark mode toggle inlined, and a post can carry a `publishAt` and stay deployed but invisible, 404 included, until the moment passes. Map tiles come from OpenFreeMap and geocoding from Photon, both public and both without a key, which is one less secret to rotate and one less bill.
The same app in a store
The phone apps are Capacitor 8 shells around the same single-page app, built with `adapter-static` instead of the Vercel adapter and with the service worker disabled, since the WebView serves the bundle itself. One codebase, one set of screens, and a fix on the web is a fix on the phone at the next build. Codemagic builds both on every push, uploads the iOS build to TestFlight and the Android one to the Play internal track. The native entry is a welcome screen that either signs you in or lets you skip into a guest session, the header keeps clear of the notch through `env(safe-area-inset-*)`, and the chat sheet follows the keyboard through `visualViewport`, because iOS lays the keyboard over the page rather than resizing it.
The first native build opened on the landing page instead of the app. On iOS the WebView origin is `capacitor://localhost`, a scheme the URL parser does not treat as special, so the initial pathname is an empty string rather than a slash and the redirect into the app never matched. One line normalises it.
What does not exist yet: travel time between stops, calendar export, OAuth sign-in, click counting on affiliate links, offline map packs, and the apps in a store. The end-to-end suite is two tests.
The pieces and how they talk. Convex is the only server we own; everything else is a static file, a redirect or a public service.