Travel Planner
A collaborative trip planner that puts every place, meal, stay, and train ride on one time axis per day. It includes real travel times, AI itinerary generation, and live co-editing.
- React
- TypeScript
- Node.js / Express 5
- PostgreSQL / Drizzle
- Zod
- WebSocket
- Auth0
- Google Maps Platform
- OpenAI
- Vitest
- Docker / fly.io
- Role
- Solo, end to end (product spec, design direction, frontend, backend, infrastructure)
- hi-fi screens designed before code
- 48
- Architecture Decision Records
- 9
- Trip routes covered by the access-control test
- 34
- per AI-generated Trip, about 30 s
- ~$0.02
Why I built it
Trip planning scatters places, restaurants, lodging, and transport across notes, map pins, and group chats. I wanted one place to arrange plans on a real time axis, check travel time between stops, save undecided ideas, and plan with my travel companions.
See it works in 1 minute: demo video
What it does
| Day time axis | Click any time to create an Event; drag to move or resize it. Overlapping Events sit side by side. Four Event kinds: Attraction, Meal, Lodging, and Transport. |
| Travel between Events | Computes how long it takes to move from one place to another with Google Routes and warns when the gap time is too short. |
| Opening-hours check | Flags Events that may fall outside a place's opening hours on that weekday. |
| AI-generated Trips | "Kanazawa, 5 days, likes hot springs" becomes a complete Trip with real Google places. |
| Collaboration | Invite Editors by email, share a read-only link, and see others' changes live. |
| Map View, Backlog, Todos, Notes | Map markers colored by Day; a holding area for undecided ideas; per-Trip and per-Event Todos; and a floating Markdown notes panel. |
| Everywhere | Desktop and phone layouts, keyboard shortcuts, English and Traditional Chinese, light and dark themes, and full backup and import. |
Architecture
flowchart TB
WEB["<b>Browser · apps/web</b><br/>React + Vite SPA"]
SHARED[["<b>packages/shared</b><br/>Zod schemas + pure domain rules<br/><i>imported by both web and API</i>"]]
API["<b>fly.io · one Docker image · apps/api</b><br/>Express 5 · REST /api/*<br/>WebSocket /api/live <i>(LiveHub · signal only, no data)</i>"]
DB[("<b>PostgreSQL</b><br/>trips · days · events · legs · todos · notes<br/><i>caches: places · place_queries · route_estimates</i>")]
EXT["<b>External services · each behind a small interface</b><br/>Auth0 <i>identity</i> · Google Places <i>PlaceProvider</i> · Google Routes <i>RouteProvider</i><br/>OpenAI <i>ItineraryGenerator</i> · Image storage <i>ImageStore</i><br/><i>Each has a fake: the app runs and is tested without API keys.</i>"]
WEB <-->|"HTTPS JSON · changed / presence over WebSocket"| API
SHARED -.->|types| WEB
SHARED -.->|validation| API
API --> DB
API --> EXTThree structural choices shape the system:
- One contract, two apps. Request and response shapes are Zod schemas in a shared workspace package. The API validates against them, and the web app derives its types from them, giving the contract a single source of truth (ADR 0005).
- Own the domain, delegate identity. My backend owns the rules for Leg lifecycle, Todos when an Event is archived, and conflict resolution. Authentication is delegated to Auth0 because it carries high security risk and offers little product differentiation (ADR 0004, 0006).
- Every external service has a fake. Places, routes, AI, image storage, and email sit behind small interfaces with fake implementations. The app runs and is tested without API keys; an environment variable selects the real provider.
Engineering highlights
Problem. LLMs can draft itineraries, but they can get facts wrong. A model might recommend a restaurant that closed or never existed, putting a fake place on someone's Trip.
Options considered
- Let the model call a place-search tool to check its picks: this takes several model turns, adds latency, and leaves the number of paid lookups unbounded.
- Let the model use web search: this costs more and still doesn't provide place IDs.
- Chosenone structured-output call, with places resolved on the server.
How it works. The model returns itinerary JSON matching a Zod schema. Each Event contains only an optional place query, such as "Uffizi Gallery, Florence". The server resolves each query to a real place ID through the place provider. If nothing matches, the Event has no location, so a made-up place cannot reach a Trip.
sequenceDiagram
autonumber
actor U as User
participant API as API
participant G as ItineraryGenerator<br/>(OpenAI)
participant N as normalizeItinerary<br/>(packages/shared)
participant P as PlaceProvider<br/>(Google Places)
participant DB as PostgreSQL
U->>API: POST /api/generations<br/>"Kanazawa, 5 days, likes hot springs"
API->>DB: 3-per-day check + insert running row<br/>(partial unique index: one running per user)
API-->>U: generation (running)
API->>G: one structured-output call
G-->>API: itinerary JSON matching Zod schema<br/>Events carry a place query, never a place ID
API->>N: normalizeItinerary(itinerary)
N-->>API: GeneratedTrip<br/>(caps Days, Events per Day, lookups)
loop each unique place query, 5 at a time
API->>DB: place_queries cache (fresh within 30 days)?
alt cache hit
DB-->>API: placeId or null
else cache miss
API->>P: findId(query)
P-->>API: placeId or nothing
API->>DB: save to place_queries
end
end
Note over API,P: No match → the Event has no location,<br/>so a made-up place cannot reach the Trip.
API->>DB: writeTripSeed(TripSeed) in one transaction
API->>DB: mark generation done (tripId, tokens)
U->>API: GET /api/generations/:id
API-->>U: done → open the new TripDetails I'm happy with
- OpenAI strict structured output requires every field, so optional fields are modeled as
nullablerather than omitted. - The model sits behind an
ItineraryGeneratorinterface. Swapping providers takes one new class; the schema, prompt, and place resolution stay the same. - Hard caps on Days, Events per Day, and place lookups appear in the prompt and are enforced on the server, since place lookups cost more than the model call.
- Cost and abuse controls include login, three generations per user per day, and a partial unique index in Postgres that allows only one running generation per user.
- The sample Trip, AI generation, and backup import all write through one
TripSeed→ database function, leaving exactly one code path that creates a Trip. - In a test run, one generation took about 30 seconds and cost about $0.02.
Google Maps is the most expensive and restrictive dependency, so its constraints shaped the design from the start.
Store IDs, cache everything else (ADR 0007). Google's terms allow place IDs to be stored indefinitely, but not the rest of the place content. An Event therefore stores only the place ID. Names, addresses, coordinates, opening hours, and photo references live in a server-side places cache that refreshes after 30 days. Trips and backups cannot retain stale Google content. This also makes it easier to swap providers.
Travel times only when asked for (ADR 0002). A Leg, the travel between two Events, is created only when the user clicks "Add Travel." It is recalculated only when its endpoints or travel mode change, and results are cached per place pair and mode. Automatically generating Legs between every pair would be more complete, but noisy and expensive.
Metered per user and per IP. Every call to Google counts against a daily allowance per user (smaller for guests) and per IP address; cache hits are free. A security review found that a guest could upload a backup with 1,000 made-up place IDs and trigger about 2,000 lookups in one request. Import is now account-only and is rejected entirely if it needs more lookups than the user has left, rather than silently importing a Trip with missing places.
When the API doesn't cover the real world (ADR 0009). Mid-project, transit Legs in Kyoto returned no route, although London worked. Google's FAQ explained why: the Routes API has no public-transit data for Japan, where transit matters most. I compared four options:
| Option | Why not / why |
|---|---|
| Japanese route provider (NAVITIME, Ekispert) | The right long-term answer, but it adds a vendor, cost, and license terms. Left as a future RouteProvider. |
| Estimate from distance | Looks like a calculation but is often wrong. |
| Hide transit in Japan | Removes the mode people actually use. |
| Let the user enter the duration | Chosen: honest and simple. The popover keeps an "Open in Google Maps" link for looking up the route. |
A manual duration is labeled as such. Changing the travel mode or a location drops it because it described a different trip. Changing only an Event's time keeps it and shows a gentle reminder that it may no longer match the timetable.
Travel companions need to see each other's changes, but they rarely edit the same field at the same time. Full co-editing with live cursors and CRDT merges would cost more than it is worth here. Version 1 uses last write wins per Event, with changes propagated promptly.
- The WebSocket carries only a
changedsignal, never the data. Clients refetch the Trip, keeping the server as the source of truth and avoiding client-side merge logic. - Each page has a
clientId, so it ignores its own changes when they echo back. - If a refetch overlaps one of the page's own saves, it is repeated so a user's edit never appears to be undone.
- Reconnects use exponential backoff (1 s → 30 s) and a heartbeat. After reconnecting, the page refetches to catch missed changes.
- Changed Events get a brief highlight ring (static when the user prefers reduced motion), and the top bar shows who else has the Trip open.
- Access is enforced on the socket too: removing an Editor or turning off a share link closes their connection. Editors see one another by initial, never by email.
sequenceDiagram
autonumber
participant A as Client A<br/>(clientId A)
participant API as API
participant DB as PostgreSQL
participant Hub as LiveHub<br/>/api/live
participant B as Client B
Note over A,B: Both pages hold a WebSocket to /api/live<br/>(hello → access check → ready + presence)
A->>API: PATCH /api/trips/:id/events/:eventId<br/>x-client-id: A
API->>DB: write the Event (last write wins per Event)
API-->>A: 200 OK
API->>Hub: publish(tripId, origin A)
par signal only, never data
Hub-->>A: changed {origin: A}
Note over A,API: origin is its own clientId → ignore
and
Hub-->>B: changed {origin: A}
B->>API: GET /api/trips/:id
API-->>B: whole Trip (server is the source of truth)
Note over Hub,B: highlight ring on the changed Event
endRules that are easy to get subtly wrong live in packages/shared as pure functions. I wrote them test-first with Vitest, before building the UI that uses them:
- Overlap layout: how overlapping Events share a Day column's width.
- Adjacency and Leg lifecycle: which Events are neighbors, including overlapping Events and Transport Events that leave from their destination, and when a Leg is kept, recalculated, or removed.
- Opening-hours conflicts: multiple periods in one day, hours that run past midnight, 24-hour places, closed days, Events that run past midnight, and Trips without dates.
The time model was a deliberate choice (ADR 0001): Events have absolute start times, not times derived by stacking one after another. A fixed commitment such as an 18:00 dinner stays put when the morning changes, and collaborators editing different Events never shift each other's plans.
This is the frontend detail I care most about, and it affects several interactions:
- Changing an Event's kind (for example, Attraction → Lodging) never drops data. Fields the new kind doesn't use are hidden but kept, with a short note explaining this; they return if the user switches back. Fields with a natural counterpart are converted (start time ↔ check-in time).
- Session expiry mid-edit: the Trip becomes read-only, but the open panel keeps what was typed and Save becomes "Log in to save." Login opens in a popup, preserving the page. If popups are blocked, the typed fields are stashed in
sessionStorage; after redirect, the panel restores them. - Unsaved changes prompt Save / Discard / Keep editing whenever the panel closes, whether by Esc, clicking another Event, or switching Trips.
- Destructive actions explain their consequences (for example, "3 Todos, including ones created by others, will be deleted"), and deletes can be undone for a few seconds.
- Access control review: an automated test calls all 34 Trip-scoped routes as an anonymous user, a stranger, a guest, and the Owner and Editor of another Trip. It asserts none receives a 2xx response and the victim's data and files stay unchanged.
- Security scan follow-up: a scan found that a malformed WebSocket upgrade URL could crash the API process without logging in. I fixed that and a log-injection issue in invitation logs.
- Uploads: image bytes are verified with
sharp, not the file extension, then re-encoded and stripped of EXIF location data. Images are never public; they're served behind the same access checks as the Trip. - Sharing: re-enabling a share link issues a new secret, invalidating old copies. Invitations can be accepted only by an account whose verified email matches. Invitation emails and verification resends are rate-limited to prevent the app from sending mail to arbitrary addresses.
How I worked
flowchart LR PRD["<b>PRD</b><br/>vocabulary, rules,<br/>non-goals table"] ADR["<b>ADRs</b><br/>9 decisions +<br/>rejected options"] BRIEF["<b>Design brief</b>"] DESIGN["<b>High-fidelity design</b><br/>48 screens<br/>light/dark · desktop/phone<br/>empty · error · read-only"] PLAN["<b>Implementation plan</b><br/>numbered steps with<br/>"Done when""] CODE["<b>Code + tests</b><br/>lint · type-check ·<br/>tests · build<br/>before every commit"] PRD --> ADR --> BRIEF --> DESIGN --> PLAN --> CODE CODE -. ""Done as / Not done" record" .-> PLAN
- Specs before code. A PRD defines the vocabulary and rules, including a non-goals table with a reason for each item (no offline editing, no live cursors, no AI edits to an existing Trip), so settled questions stay settled.
- Decisions are written down. Nine Architecture Decision Records capture lasting choices and the options I rejected.
- Design first. A high-fidelity design of 48 screens (light and dark, desktop and phone, empty, error, and read-only states) guided implementation.
- Small, verifiable steps. The implementation plan breaks work into numbered steps, each with a "Done when" checklist and a "Done as / Not done" record. Lint, type-check, tests, and build must all pass before every commit.
- Working with AI coding agents. Validate each step, especially real-world use cases. Keep planning and design documents always up-to-date so sessions handover to each other without problems. Use worktrees for parallel and accelerated development.