TravelCanvas · 2026 – present

A person is not a user

Building an itinerary platform advisor-first, where most of the hard problems turned out to be naming problems.

PublicNamed and linkable, with figures as reported.
2
audiences, one productTravel advisors who build trips, and the travellers on them.
0
owner columnsPermissions derive from membership, not from an owner field.
8
kinds of trip itemFlights, hotels, dinners, tours, and the rest.

The situation

A trip arrives as a dozen unrelated confirmation emails — flights from one airline, two hotels, a car, a restaurant, a tour. There is no single view of what happens when, no reliable way to share it with the people coming along, and no way for an advisor and their client to work on it together.

I co-founded it in February 2026 on a trip through Southeast Asia, which is roughly the most on-brand possible origin for an itinerary tool.

The constraints

  • Two audiences with opposed needs in the same product: advisors are building and want control, travellers are reading and want clarity. Advisors are the paying side, so the product is built advisor-first without the traveller experience being an afterthought.
  • Most people on a trip will never create an account. The advisor types in their client's name, and their partner's, long before anyone signs up — and the trip has to be correct in the meantime.
  • The documents that describe a trip — confirmation emails, booking PDFs — are produced by systems the product will never control, in every layout their senders have invented, and the dates and names inside them have to come through exactly.
  • A small founding team, so every decision that adds ongoing maintenance has to earn it against the hours two people actually have.

The call

Separate people from users. `people` is the identity record; `users` is the login. A member of a trip points at a person and has no user column at all — whether someone can sign in is derived the other way round. This is the decision the whole permission model rests on, and it exists because a client is on the trip well before they are on the platform.

Give an itinerary no owner column. There is no `owner_id`; who may do what is derived entirely from the members table. There is a `created_by_id`, and it deliberately grants nothing — permissions and quota never consult it. Ownership fields tend to become a second, quieter permission system that disagrees with the real one.

Keep a canonical glossary and treat colliding vocabulary as a defect. "Advisor" is the product word; the code says `manager` everywhere. "Manager" means two unrelated things — a user role and a per-trip member role. Those collisions caused real mistakes, so the fix was a document that says which word wins where, and other docs are not allowed to redefine them.

Split the authenticated surface into separate applications for traveller, advisor, and admin over one app with role-based routing — with the shared API client, stores, and query layer in packages both consume, so the split is in the UI rather than duplicated underneath.

Read forwarded confirmation emails and uploaded PDFs with Claude rather than writing a parser per provider. Bookings arrive in whatever layout each airline, hotel or booking site invented, and a set of templates is permanently behind that long tail — a model reads the format nobody anticipated. PDFs go to the model as documents rather than extracted text, so a scanned confirmation works the same as a digital one.

Give the importer a review inbox instead of write access. Extraction only ever produces a draft; items reach the trip when a person approves them, and not otherwise — the email path included, though nobody is at a screen when it runs. Two failure modes force the rule independently: a model can misread a date, and a forwarded document is written by a stranger, so its contents are data to review, never instructions to act on.

Take the traveller app to the App Store as a Capacitor shell rather than a second, native codebase. Two founders cannot maintain a Swift app beside three web applications, and the shell ships the same Vue app — a fix lands on the web and on the phone from the same commit. Where the platform genuinely differs the app goes native rather than pretending: sign-in runs in Google and Apple's own sheets, because the web's redirect flow leaves the session in the system browser rather than in the app; and the opt-in Face ID / Touch ID lock is deliberately a gate in front of the interface rather than a cryptographic binding, so a changed face or fingerprint can never strand a traveller outside their own itinerary.

Make an App Store release one command. `pnpm submit:ios` attaches the newest TestFlight build to a version, writes the release notes, submits for review, and tags the commit it shipped — the tag because Apple keeps no usable record of which commit a build came from, and reconstructing one after the fact was tried and failed. On a two-person team a release has to be cheap enough to happen often, and a process that lives in browser tabs is neither cheap nor recorded.

How it fits together

AdvisorTravellermay have no loginItinerary + memberspermissions derivedTraveller appAdvisor appAdmin
People, membership, and the three surfaces built on them

What happened

An advisor builds a trip privately and publishes it, which is the single action that makes it visible and sends invitations. When they want a decision rather than to inform, they group options into a proposal and the client approves or declines.

Because membership rather than ownership carries permissions, the awkward cases stopped being special: a client who never signs in, an advisor handing a trip to a colleague, a traveller on someone else's itinerary. None of them needed their own branch.

A forwarded confirmation email comes back as structured items in a review inbox, and approving them is what puts them on the trip. The importer's mistakes are cheap by design: a misread date is corrected in review rather than discovered mid-trip.

The same application travellers use on the web is live on the App Store — signed in through Google and Apple's native sheets, optionally locked behind Face ID — and releasing it is one command, from attaching the build to tagging the commit that shipped.

What I'd do differently

The glossary should have been the first document, not a response to bugs. I wrote it after the same word meaning two things had already cost real time — and the version that exists now opens with the collisions rather than the definitions, because that is the part that was actually load-bearing.

Built with

TypeScript · Vue 3 · Vite · Capacitor · Hono · PostgreSQL · Drizzle ORM · Redis · BullMQ · Zod · Claude API