TopicsThe skeleton

PRDs

Not under review yet · My comments

The skeleton

A trip's big commitments, its legs, transport and lodging, on a map and a timeline, with every time in its own place's zone.

Summary. Step 2 of the build order. The skeleton is the set of big, hard-to-change commitments of a trip (Lock the skeleton), plus the lodging that covers its nights (Fill in).

  • Legs: where the trip goes, in order, and the nights in each place.
  • Transport: flights, trains, ferries, drivers and transfers between them, each listing who is on it.
  • Lodging: a stay for every night, booked or with family, with a check that no night is uncovered and nobody is unhoused.
  • One map and one timeline of all of it, every time shown in its own place's zone, with a "next day" marker on overnight arrivals.
  • Every record belongs to a trip and is shown by the viewer's role on it (Trips and members).

Status: draft 2, 2026-10-25. Nothing here is built yet. Mike reviews this draft before step 2 starts. The choices that need him are the surveys under Open questions, each with a recommended answer.

Changed since draft 1, after a review against the code and the main PRD:

  • Followers see a stay as a pin on its town, not on the house, so the map no longer shows what the address rule hides. The reason now cites what the sources say.
  • Offline tiles come through the API, since the media bucket is private.
  • Checks: a night on a plane or train is covered, long layovers are flagged only for flights and trains, and the route itself is checked (legs that overlap, nights in no leg, trip dates that run past the route).
  • Stays with family, with no booking.
  • Trip dates set and widen from the legs and never narrow by themselves.
  • Times are worked out again when the server's time zone data changes.
  • Account deletion also takes the travelers you are guardian of off every segment and stay.
  • Reuse: the time helpers sit beside common's datetimes.js and intl_data.js. geo-tz's memory was measured, and airports are kept only with IATA codes.
  • Two new surveys, on invitees on segments and on notices of changes. The survey on who is on a new segment now covers a planner who is not going.

Goals

  1. A planner can lay out the whole trip (where, how long, how they get there, where they sleep) in one sitting, and everyone on the trip sees the same plan.
  2. Every time is right in its own place: a flight leaves at 23:40 in Los Angeles and lands at 06:15 two days later in Hanoi, and both read that way.
  3. The plan says what is missing: a night with no bed, a traveler on a flight but in no room, an arrival long before check-in.
  4. The map and the timeline cost nothing to run at a family's scale.

Non-goals

  • Bookings. Confirmation numbers, tickets, seats, loyalty numbers, payments and evidence come in step 4 (Bookings in) and attach to the records built here. The skeleton holds what was planned and its state.
  • Fare and schedule research, and AI comparing options. Later, on top of this data.
  • The days (morning, afternoon, evening, meals, activities): step 3.
  • Offline maps. Section 9 of the main PRD needs them for travel mode (step 6). This step picks a map stack that can be made offline later.
  • Checks across trips (landing from one trip at dawn and leaving on the next that night). They need every trip a person is on; see Phasing.

How it works

Places

Everything in the skeleton happens somewhere, so a place comes first: a name, coordinates, a country, and its time zone (an IANA name such as Asia/Ho_Chi_Minh). A planner finds one by typing a name and pressing Search, then picking a result; an airport can also be found by its code (SGN). The time zone is looked up from the coordinates once, when the place is saved, and shown beside it so a wrong one is noticed.

A place belongs to its trip. The same city on two trips is two places, so one trip's lodging address never shows on another.

Legs

A leg is one stop on the route: a place and its dates (arrive on, leave on), in order. Its nights follow from the dates. Leaving one leg and arriving at the next on the same day is the normal case. A day trip is not a leg; it belongs to the days (step 3).

The trip's dates follow the legs outward, never inward. A trip with no dates takes them from its first and last legs. A leg outside the trip's dates widens them, and the planner is told so beside Save. Removing or shortening a leg never narrows them by itself: when the route no longer reaches the trip's first or last day, the checks say so, and the planner changes the dates on the trip, where they are set today.

Transport

A segment is one movement between two places: a flight, train, bus, ferry, rental car, hired driver or transfer.

  • Departure and arrival are each a local date and time in their place's zone. An arrival on a later calendar day is shown with "+1" (or "+2").
  • One journey, several segments. Connecting flights, or a driver who carries the group across three legs over four days, are one journey holding its segments in order, so they are planned, moved and (in step 4) booked together.
  • Layovers. In a journey of flights or trains, the time between two segments is a layover: a warning when it is under an hour for a flight, and when it is over eight hours. Ground journeys are not checked this way, since a driver's segments are days apart by design.
  • Ground transfers are first-class: a pickup window (from and until), who arranged it and how to reach them, the vehicle and seats, a price in its currency, and the driving time. A drive of five hours or more is shown as most of a day.
  • Flights carry the airline and flight number. Their airports are places, found by code.
  • Who is on it: every segment lists its travelers, chosen from the trip's travelers (members with the traveler, co-planner or organizer role, and travelers without an account). Who is ticked on a new segment at first is a survey below, and so is whether someone invited but not yet accepted can be ticked. Different home cities work the same way: each person's route in is its own journey, with only them on it, and the timeline shows everyone's arrivals side by side.

Lodging

A stay is a place to sleep, of one of two kinds:

  • Booked: a hotel, a rental, a hostel. A name, its place (the address), check-in and check-out dates, and the usual check-in and check-out times in the place's zone.
  • With family or friends (section 5.4 of the main PRD): the host's name and how to reach them, and the same place and dates, with no booking and no check-in times. It covers its nights like any stay, and the early-arrival check skips it.

Each stay lists who sleeps there, chosen as for segments. Rooms within a stay, and the arrival card (entry codes, Wi-Fi, the host's contacts), come later: rooms with the days, the arrival card with bookings, since its details come from the host's emails and some of them are secrets (section 9 of the main PRD).

States

Every leg, segment and stay has a state: idea, proposed, agreed, booked, confirmed (Lock the skeleton). For a stay with family, "booked" and "confirmed" mean the host said yes. The map and timeline show ideas and proposals faintly, so the firm plan stands out. Proposals and votes on them are step 3's.

Checks

Shown on the timeline beside the thing they are about, and in a short list at the top of the trip's skeleton:

  • A night with no stay for someone on the trip that night, or a stay with nobody in it. A segment covers every night it is under way: from its departure's local date to the night before its arrival's local date. So the flight that leaves Los Angeles at 23:40 on December 18 and lands in Hanoi at 06:15 on December 20 covers the nights of the 18th and 19th, and neither is flagged.
  • Someone in two places: on a segment while their stay elsewhere has not ended, or on two segments at once.
  • Overnight arrivals: an arrival placed on the day it actually lands, so a booked night never used, or a needed night never booked, shows. An arrival more than three hours before check-in at a booked stay suggests an early check-in or a day room.
  • Layovers too tight or very long, in journeys of flights or trains (above).
  • The route itself: two legs whose dates overlap (the next one arriving before the last one leaves); nights in no leg between two legs that no segment covers; and trip dates that run past the first or last leg.
  • A leg with no way in or out, when the leg before or after it is in another place.

Notices of changes

The people on a segment or stay are the ones a change hurts: a flight moved to the morning, a stay's dates shifted, a booking undone. Whether this step tells them, and how, is a survey below. If it does, the notice goes to the people on the record, through the bell that already carries invitation notices (Trips and members), and links to the record on the timeline.

The map

All the trip's places as pins: legs numbered in order, stays as beds, airports as planes, with segments drawn between them (a straight line for flights, along the road for drives once routing exists; a straight line until then). Tapping a pin opens what happens there. For followers, a stay's pin sits on its town, not on the house (Who sees and does what). The map fits the trip on open and works on a 390 px phone.

The timeline

The trip day by day, one row per calendar day of the trip: the leg the day is in, the segments that day with their local times, and the stay that night. A day can be split by person when the group is not together, so "who is where" on any day is one look. Times are always the place's own; the viewer's home zone is shown only on request ("in your time").

Time zones

  • What is stored: the wall-clock time as the booking says it ("06:15 on December 20"), the place's zone name, and the UTC instant that pair means, kept for sorting and the checks. The wall-clock time is the truth, and nothing that a person typed changes. When a country changes its rules, Node's time zone data changes with a Node upgrade (it reports its version as process.versions.tz). The API records that version in travel.app_meta. When it starts with a different one, the jobs process works the instants out again for every segment and stay that has not happened yet. Past ones keep theirs.
  • Time zone from a location is missing from common (main PRD, External capabilities). Recommended: geo-tz, an npm package that looks the zone up offline from the coordinates, from the timezone-boundary-builder data that most such tools use. Cost: none. No service, no key, no request leaves the server. It runs in the API, not the browser, and goes into common beside the geocoding below, so the next app gets both.
    • Its default entry, not geo-tz/now. The now data merges zones that keep the same clock today: it answered Asia/Jakarta for Ho Chi Minh City, where the default entry answers Asia/Ho_Chi_Minh. That is the name a person reads beside the place.
    • Memory, measured on home1 with geo-tz 8.1.10 on Node 22 (2026-10-25): it is 72 MB on disk. Loaded lazily (its default), the process was at 54 MB, and 500 lookups spread over the world added 7 MB. Its preCache() loads every boundary at once: 1.4 GB, or 0.8 GB with the now data. The API never calls it. The lazy cache grows with the regions looked up, which a family's few dozen places keep small.
  • Airports come from OurAirports' airports.csv, published as public domain. Of its roughly 86,000 sites, the table keeps the airports with an IATA code (about 9,000), so a code search never offers a heliport or a closed strip. Each airport's zone is looked up with geo-tz at load. The jobs process (app.jobs) reloads the file once a month. Cost: none.
  • Arithmetic. In the browser, display reuses common's js_ui: datetimes.js (formatWhen, a time in a given zone and format) and intl_data.js (zone names, canonicalZone, zoneOffsetLabel). What common lacks is the other direction: an instant from a wall-clock time and a zone, the "+1" day count, and layover lengths. That goes in one DOM-free ES module beside them, common/js_ui/esm/zoned_time.js, on the platform's Intl data. The API imports it as well as the pages, so no page and no route does its own. It is tested on the days a clock changes, in both directions.

Maps and geocoding

  • Interactive maps are missing from common. Recommended: MapLibre GL JS (open source, BSD license, no key) drawing OpenFreeMap's vector tiles: free, no key, no stated limits, OpenStreetMap data, with its attribution kept on the map. Cost: none. The catch is that OpenFreeMap is a free public service with no promise of uptime. The tile host joins the page policy's connect-src and img-src, and MapLibre's workers need worker-src blob:.
  • Our own tiles are the fallback: a Protomaps extract of the countries a trip visits, as one file in the media bucket (Backblaze B2), for a few cents a month of storage. The same file later works offline. It is served through the API, not read from the bucket. The bucket is private, and B2 makes a whole bucket public or private, not a prefix. The page policy also lets the browser reach the bucket only at its path, for upload parts (proxy.ts, dev_mgr S11), while pictures and media come through the API. So an API route answers the map's range requests from the file, behind the same sign-in as the trip. The tiles are then on the site's own origin: nothing joins the policy, and the offline copy later caches same-origin responses. The cost is the API's bandwidth for tiles, small at a family's scale. MapLibre takes either source by changing one style address, so the choice can change without touching the pages.
  • Geocoding exists in common only in Python (common/location, OpenStreetMap's Nominatim, with a cache and the policy's rate limit). Recommended: a TypeScript port of that client into common, used by the API, with its cache in Postgres and Nominatim's rules kept: at most one request a second from the site, an identifying user agent, and no search-as-you-type (their policy forbids autocomplete), which is why the planner presses Search. Cost: none. A family plans a few dozen places a trip, far inside the limit. It lives in common/location/js. Common's TypeScript packages join Travel's workspace one by one, so it needs its own line in pnpm-workspace.yaml, and its tests run in common's gate and in Travel's pnpm check:full.
  • Paid providers are the way out if a free one fails us, each a configuration change: Stadia Maps (tiles and geocoding with autocomplete; its free plan is non-commercial only, paid from $20 a month), MapTiler (free plan non-commercial, 5,000 map sessions a month; paid from $30 a month), Google Maps (per-load pricing past a monthly free amount). None is needed at this scale; see the survey below.

Who sees and does what

Every route asks roleOn from services/api/src/trips.ts first. Someone not on the trip gets 404 for the skeleton and for every record in it, exactly as for the trip itself.

Organizer Co-planner Traveler Follower
See the map and the timeline yes yes yes yes
See who is on each segment and stay yes yes yes yes
See a stay's street address, and its pin on the house yes yes yes no: its name and town, with the pin on the town
Add, change and remove legs, segments and stays yes yes no no
Change a record's state yes yes no no

Followers are on the trip but stay home, and Trips and members says they "will see the plan and the album, not documents or locations, when those exist". Section 9 of the main PRD keeps the arrival card's secrets (door codes, Wi-Fi passwords) from followers, and makes live location opt-in. Neither says where a follower may know the group sleeps. That is the follower-addresses survey below. Its recommendation keeps the house off a follower's screen in both places it could show: the address, and the pin.

Your data: the account export gains the skeleton of each trip you are on, as you would see it. Deleting a trip deletes its skeleton. Deleting your account removes your place on the trip, and with it you come off every segment and stay. The travelers you are guardian of come off too, since Trips and members removes them with you.

Data model

All in the travel schema, each row with its trip_id (deleted with the trip), so no query can reach a record without naming a trip the viewer is on.

Table Holds
trip_places trip_id, name, latitude, longitude, country code, tz (IANA name), the geocoder's reference, an airport_code when it is one
trip_legs trip_id, place_id, position, arrives_on, leaves_on (dates), state, notes
trip_journeys trip_id, kind (flight, train, bus, ferry, rental car, driver, transfer, other), title, state
trip_segments journey_id, position, from_place_id, to_place_id, departs_local and arrives_local (wall-clock times, no zone), departs_at and arrives_at (UTC instants, worked out from the place's zone), carrier and number, pickup window, vehicle, seats, arranged by and their contact, price and currency, driving minutes, state
trip_stays trip_id, place_id, kind (booked, with family), name, host and their contact, check_in_on, check_out_on, check-in and check-out times, state, notes
segment_travelers, stay_travelers one row per member on it (trip_members.id, deleted with the member, so a removed member or ward leaves every segment and stay)
airports from OurAirports, IATA codes only: code, name, city, country, coordinates, zone. Shared by every trip, since it holds nothing about anyone

A zone is stored on the place, never copied onto the times, so correcting a place's zone corrects every time there. The zone data version the instants were worked out with is one row in travel.app_meta.

Phasing

  1. Places, legs, the map and the timeline: geocoding and time zones in common, the map stack, legs on both views, and the route checks.
  2. Transport: journeys and segments, airports by code, who is on each, layovers and "+1" markers.
  3. Lodging and the checks: stays of both kinds, who sleeps where, uncovered nights, overnight arrivals, someone in two places.
  4. Checks across trips: each traveler's trips together, for overlaps, tight turnarounds and airport changes.

Each phase is usable on its own, and the December trip's flights can go in after phase 2.

Open questions

Answer each question in place; your answers are saved for everyone reading this page.

Where should the map's tiles come from? See Maps and geocoding.

Recommended (default)

How should a planner find a place? See Maps and geocoding.

Recommended (default)

What should followers see of where the group sleeps? See Who sees and does what.

Recommended (default)

Who changes the skeleton? See Who sees and does what.

Recommended (default)

Who is on a new segment or stay at first? See Transport. Organizers and co-planners count as travelers, and trips have no "not going" mark, so a planner who stays home is on every new segment at first.

Recommended (default)

Can someone invited but not yet accepted be put on a segment or stay? See Transport. Families plan before everyone has answered the email, and today an invited person has no role on the trip until they accept.

Recommended (default)

Should the people on a segment or stay be told when it changes? For example, a flight time, a place, a stay's dates, or a state going back from booked. See Notices of changes.

Recommended (default)

Comments

Loading comments...