Files
grabowski e342cd9a91 Add trip checklists with rule-based packing advice
Each trip gets a checklist whose items group under free-text categories
(Documents, Clothing, Toiletries, Health, Electronics, Extras first, then
any custom ones alphabetically). Items are either shared — every member
sees and can tick them, and checked_by records who — or personal to one
member, which nobody else can see or touch. Items carry an optional
quantity, drag-reorder within their category, and "Uncheck all" resets the
list for the trip home.

The "Suggestions" modal is deterministic, offline advice derived from the
trip itself (src/server/util/packing.js) — no LLM and no external calls, so
it stays unit-testable and works on a self-hosted box. Nights scale
clothing quantities, flights add liquids/power-bank/check-in, rentals add
licence + IDP, ferries add motion-sickness tablets, tropical stops add sun
cream and repellent, and the destination country picks the plug type from a
bundled ~50-country table. Every suggestion carries a short reason, and
already-added ones are keyed by suggestion_key so they can't be duplicated.

Two rules deliberately differ from the naive reading, both regression-tested:
a latitude floor stops a December trip to Bangkok being tagged cold as well
as tropical, and only a flight segment's arrival airport counts, since the
first segment's departure airport is home rather than a destination.

checklist_items is a new table, so the existing CREATE TABLE IF NOT EXISTS
path creates it on upgrade; no MIGRATIONS entry is needed and existing data
is untouched.

docs/API.md documents the full contract. 113/113 tests pass.
2026-08-03 18:18:25 +07:00

32 KiB
Raw Permalink Blame History

Trip Plan — API Contract

All endpoints are JSON over REST, prefixed with /api. This document is the binding contract between backend and frontend. Do not deviate; extend only by agreement.

Conventions

  • Dates: YYYY-MM-DD strings. Times: HH:MM 24h strings (optional fields).
  • Auth: cookie session (cookie-session). All endpoints except account (create) / login require auth → otherwise 401 {"error":"unauthorized"}.
  • Identity is Mullvad-style: an account is a random account token (the only credential — no username/password). Users additionally have a non-unique, editable display_name for humans.
  • Trip access: user must be a member of the trip → otherwise 404 {"error":"not found"} (don't leak existence).
  • Errors: 4xx/5xx with body {"error": "<human readable message>"}.
  • IDs are integers.

Server layout

  • src/server/app.js — builds and exports the Express app (export function createApp(dbPath) and export default a ready app is fine, but createApp must exist for tests).
  • src/server/index.js — reads env (PORT default 3000, DATA_DIR default ./data, SESSION_SECRET default dev value with console warning), ensures DATA_DIR exists, starts listener, serves public/ statically.
  • src/server/db.jsbetter-sqlite3 connection + schema creation (idempotent CREATE TABLE IF NOT EXISTS).
  • src/server/routes/auth.js, trips.js, entries.js, geocode.js, checklist.js.
  • src/server/util/distance.jshaversineKm(lat1, lng1, lat2, lng2) returns km (number).

Data model (SQLite)

users        (id INTEGER PK, token_hash TEXT UNIQUE NOT NULL,   -- sha256 hex of the account token
              display_name TEXT NOT NULL,                        -- auto-generated, editable, NOT unique
              created_at TEXT DEFAULT current_timestamp)
trips        (id INTEGER PK, name TEXT NOT NULL, start_date TEXT NOT NULL, end_date TEXT NOT NULL,
              owner_id INTEGER NOT NULL REFERENCES users(id),
              currency TEXT NOT NULL DEFAULT 'USD',   -- 3-letter code, display only (no FX)
              join_code TEXT UNIQUE NOT NULL,         -- share code for joining the trip
              created_at TEXT DEFAULT current_timestamp)
trip_members (trip_id INTEGER REFERENCES trips(id), user_id INTEGER REFERENCES users(id),
              role TEXT NOT NULL DEFAULT 'editor',  -- 'owner' | 'editor'
              sort_order INTEGER NOT NULL DEFAULT 0, -- per-user dashboard order (drag & drop)
              PRIMARY KEY (trip_id, user_id))
entries      (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id),
              date TEXT NOT NULL,          -- start date
              end_date TEXT,               -- optional inclusive end date (multi-day entries; null = single day)
              type TEXT NOT NULL,
              title TEXT NOT NULL, details TEXT DEFAULT '',
              start_time TEXT, end_time TEXT,
              location_name TEXT, lat REAL, lng REAL,
              sort_order INTEGER NOT NULL DEFAULT 0,
              price REAL,                             -- null = no cost tracked
              paid_by INTEGER REFERENCES users(id),   -- who paid (null = unassigned)
              split_mode TEXT NOT NULL DEFAULT 'equal', -- 'equal' | 'own' | 'payer'
              segments TEXT,                          -- JSON array, flight entries only (see below)
              rental TEXT,                            -- JSON object, rental entries only (see below)
              transport_mode TEXT,                    -- transport entries only (see below)
              auto_ref TEXT,                          -- JSON {from,to} stay ids; non-null = auto-created transport (see below)
              waypoints TEXT,                         -- JSON [{lat,lng,name?}] scenic via-points, transport entries only (see below)
              created_at TEXT DEFAULT current_timestamp)
entry_participants (entry_id INTEGER REFERENCES entries(id), user_id INTEGER REFERENCES users(id),
              PRIMARY KEY (entry_id, user_id))
              -- no rows for an entry = "all trip members participate" (dynamic default)
checklist_items (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id),
              user_id INTEGER REFERENCES users(id),   -- null = shared trip item; else personal to that user
              text TEXT NOT NULL,                     -- what to pack/do, ≤120 chars
              category TEXT NOT NULL DEFAULT 'General',
              qty INTEGER,                            -- optional count (null = unspecified)
              checked INTEGER NOT NULL DEFAULT 0,     -- 0/1
              checked_by INTEGER REFERENCES users(id),-- who ticked it (null when unchecked)
              sort_order INTEGER NOT NULL DEFAULT 0,
              suggestion_key TEXT,                    -- non-null = added from packing advice (dedupe key)
              created_at TEXT DEFAULT current_timestamp)

Entry typeflight | transport | activity | rental | stay | note.

Legacy types (removed 2026-07): hotel, travel, immigration. Idempotent data migrations run at startup: hotel rows become stay, travel rows become transport, immigration rows become activity with the title prefixed 🛂 . POST/PATCH with a legacy type is a 400 (invalid entry type).

Transport entries

transport covers ground/sea travel (the old travel type). Optional transport_modetrain | bus | ferry | taxi | drive | other (nullable; drives the icon in the UI). Validation: only allowed when the effective type === 'transport' (else 400 transport_mode is only allowed on transport entries); PATCH transport_mode: null clears it. Entry JSON always includes transport_mode (string or null).

Scenic waypoints — a transport entry may carry waypoints: an ordered JSON array of via-points the road route should pass through (e.g. a mountain pass instead of the highway). Shape [{"lat": 46.5, "lng": 10.45, "name": "Stelvio Pass"}]. Validation: only allowed when the effective type === 'transport' (else 400 waypoints are only allowed on transport entries); array of 08 objects; each needs lat/lng (finite, lat [-90,90], lng [-180,180]); name optional string ≤120 chars; waypoints: null or [] clears them. Entry JSON always includes waypoints (parsed array or null; [] is normalized to null on store). Not accepted on non-transport types.

Waypoints attach to a route leg (see Route below): a ground leg between two located stops is drawn through the via-points of the transport entry that bridges it.

Auto-transport between stays: when a stay entry is POSTed and it has a chronological neighbour stay (the nearest stay before and/or after it, ordered by date), the server auto-creates one transport entry per neighbour pair — titled "<from short name> → <to short name>" (short name = first comma-segment of the stay's location_name, fallback title), dated on the later stay's date, transport_mode null, no price/location, sort_order 0, and auto_ref set to {"from": <earlier stay id>, "to": <later stay id>} (stored JSON, returned parsed; null on every other entry — the marker for "auto-created"). Skipped when any transport or flight entry already exists with date between the earlier stay's end (end_date or date) and the later stay's date (inclusive). One-shot: fires only on stay creation (never PATCH), so deleting an auto-created transport does not resurrect it. The POST response is unchanged (201 {entry} = the stay); clients should refetch the entry list.

Regenerating auto-transportsPOST /api/trips/:id/transports/regenerate (any member, no body) reconciles auto transports after stays have been moved/reshuffled:

  1. Compute the desired pairs: stays in chronological (date, id) order → each adjacent pair (earlier, later), skipping pairs whose window (earlier.end_date||date .. later.date, inclusive; skip if inverted/overlapping) contains a manual transport (auto_ref null) or any flight.
  2. Existing auto transports (type='transport', auto_ref non-null) whose {from,to} matches a desired pair are kept but re-synced: date set to the pair's current transition date (later.date), title regenerated to the current "A → B"; transport_mode, price/split, times, details are preserved. Auto transports matching no desired pair are deleted.
  3. Missing desired pairs get a fresh auto transport (same shape as creation-time).

Response: 200 {created, updated, deleted} (counts). Auto transports from databases predating auto_ref have it null and are treated as manual (never touched). Stays MAY overlap in dates (two locations on the same day, e.g. temporarily while reshuffling) — overlapping adjacent pairs simply produce no auto transport, and the calendar renders overlapping stays in stacked band lanes.

Multi-day entries & stays

Any entry may carry an optional end_date (inclusive, YYYY-MM-DD, must be ≥ date; PATCH end_date: null clears it). Use cases:

  • Flights/travel longer than 24 h — dep on date at start_time, arr on end_date at end_time.
  • stay entries ("area blocks") — "3 days Venice", "7 days Berlin": a location plus a date range. The frontend renders stays as continuous bands across the calendar days (all-day-event style), not as chips; other multi-day entries show a chip on the start day and lightweight "…continues" markers on the days up to end_date.

Stays behave like normal entries otherwise: optional geocoded location (feeds the route as one stop, positioned at the start date), optional price/split (e.g. an apartment for the whole block).

Summary additions: stays (count) and areas — stay entries in chronological order as [{ "name": "Venice", "days": 3 }] where name is the stay's location_name (fallback: title) and days is the inclusive span (end_date null → 1). Example: "areas": [{"name":"Venice","days":3},{"name":"Berlin","days":7}].

Rental car details

rental-type entries can carry structured rental data in entries.rental (TEXT, JSON — same storage pattern as flight segments). null/absent = plain entry.

"rental": {
  "brand": "Toyota", "model": "Yaris Cross", "car_type": "SUV",
  "booking_ref": "RC-889231", "included_km": 1500,
  "pickup":  { "date": "2026-08-01", "time": "09:00", "location_name": "CNX Airport",
               "lat": 18.77, "lng": 98.96 },
  "dropoff": { "date": "2026-08-07", "time": "18:00", "location_name": "Chiang Mai Old Town",
               "lat": 18.79, "lng": 98.98 }
}

Validation (POST/PATCH entries): only allowed when type === 'rental' (else 400); all fields optional; brand/model ≤60, car_type ≤40, booking_ref ≤60 chars; included_km null (= unlimited/unknown) or number ≥ 0; pickup/dropoff objects with date (YYYY-MM-DD, required within the object), optional time HH:MM, location_name ≤120, lat/lng both-or-neither in range. PATCH rental: null clears. Entry JSON always includes rental (parsed or null). The entry's own date should be the pickup date (frontend sets this); the entry's price/split fields carry the rental cost as usual.

Summary impact: summary.rentals counts rental entries; summary.includedKm = sum of non-null included_km across rentals, or null when no rental specifies one — the frontend pairs it with kmDriven ("≈ 520 km driven / 1,500 km included"). Rental pickup/dropoff locations do NOT feed the route (use the entry's normal location field for a map point if wanted).

Flight segments

Flight entries can carry structured segments for multi-leg itineraries (e.g. CNX→BKK→DXB→FRA = 3 segments). Stored as JSON in entries.segments, returned parsed. null/absent = no segments (plain entry).

"segments": [
  { "flight_no": "TG103", "dep_time": "10:30", "arr_time": "11:45",
    "from": {"code": "CNX", "name": "Chiang Mai Intl", "lat": 18.77, "lng": 98.96},
    "to":   {"code": "BKK", "name": "Suvarnabhumi",    "lat": 13.68, "lng": 100.75} },
  { "flight_no": "EK385", "dep_time": "13:05", "arr_time": "17:10",
    "from": {"code": "BKK", "name": "Suvarnabhumi",    "lat": 13.68, "lng": 100.75},
    "to":   {"code": "DXB", "name": "Dubai Intl",      "lat": 25.25, "lng": 55.36} }
]

Validation (POST/PATCH entries): only allowed when type === 'flight' (else 400); array of 18 objects; each needs from and to with code (24 uppercase alphanumerics); name optional ≤80 chars; lat/lng both-or-neither, same ranges as entry lat/lng; flight_no optional ≤12 chars; dep_time/arr_time optional HH:MM. PATCH with segments: null clears them.

Route expansion: an entry whose segments have coordinates contributes airport stops to /route instead of its own lat/lng — from of the first segment, then to of every segment, in order (airports without coords are skipped; consecutive duplicate coords are collapsed). Stops derived this way have "kind": "airport" and include "code". So CNX-BKK-DXB-FRA yields 4 stops and 3 measured legs on the map.

Airports lookup

GET /api/airports?q=<query>200 {results: [{code, name, city, country, lat, lng}]} (max 8; requires auth).

Backed by a bundled dataset at src/server/data/airports.json generated from the public-domain OurAirports data (filter: has IATA code + scheduled service; keep code/name/municipality/iso_country/lat/lng). Matching: exact/prefix IATA code match first (case-insensitive), then name/city substring. This makes CNX → Chiang Mai Intl instant and offline.

Cost semantics

  • Only entries with non-null price count toward costs.
  • Effective participants of an entry = its entry_participants rows, or all current trip members if it has none.
  • split_mode:
    • equalprice is the TOTAL; split equally among effective participants ("rental car 50/50"). paid_by is credited with having paid the total.
    • ownprice is PER PERSON; each effective participant owes and pays their own ("everyone pays their own flight"). No debt created. Effective total = price × participant count.
    • payer — personal expense: paid_by owes and pays the whole price alone (requires paid_by; validation error otherwise).

Endpoints

Auth (Mullvad-style account tokens)

Method & path Body Response
POST /api/auth/account {} (empty) 201 {user: {id, display_name}, token: "XXXX-XXXX-XXXX-XXXX"} — creates an account, logs it in. The raw token is returned ONLY here, never again.
POST /api/auth/login {token} 200 {user}; 401 on unknown token
POST /api/auth/logout 204
GET /api/auth/me 200 {user: {id, display_name}} or 401
PATCH /api/auth/me {display_name} 200 {user}; validates 140 chars after trim

Token rules:

  • 16 chars from the unambiguous alphabet ABCDEFGHJKMNPQRSTUVWXYZ23456789 (no I/L/O/0/1), generated with crypto.randomBytes, displayed grouped XXXX-XXXX-XXXX-XXXX.
  • Login normalizes input: strip dashes/spaces, uppercase, then compare sha256(token) against users.token_hash. Only the hash is stored.
  • display_name is auto-generated at account creation as adjective-animal (e.g. brave-otter) from two small built-in word lists; not unique; user-editable via PATCH.

User JSON shape everywhere: {id, display_name}.

Trips

Method & path Body Response
GET /api/trips 200 {trips: [{id, name, start_date, end_date, owner_id, currency, role, sort_order, member_count, entry_count}]} (trips where user is member, ordered by the caller's trip_members.sort_order, then newest first for ties)
POST /api/trips {name, start_date, end_date, currency?} 201 {trip}; validates: name non-empty ≤120 chars, valid dates, end_date >= start_date, range ≤ 365 days, currency (if given) matches ^[A-Z]{3}$ (default USD). Creator becomes member with role owner.
GET /api/trips/:id 200 {trip: {id, name, start_date, end_date, owner_id, currency, join_code}, members: [{id, display_name, role}], entries: [entry…]} entries ordered by (date, sort_order, id)
PATCH /api/trips/:id any of {name, start_date, end_date, currency} 200 {trip} (same validation; entries outside new range are kept)
PATCH /api/trips/:id/order {sort_order} 200 {sort_order} — updates the CALLER's own trip_members.sort_order for this trip (any member; integer required, else 400). Per-user: does not affect other members' dashboard order.
DELETE /api/trips/:id 204 — owner only, else 403. Deletes members + entries too.
POST /api/trips/join {code} 200 {trip} — joins the trip with that join_code as editor (idempotent: already a member → still 200 {trip}); 404 {"error":"not found"} on unknown code. Code normalized like tokens (strip dashes/spaces, uppercase).
POST /api/trips/:id/join-code 200 {trip} — regenerates join_code, owner only (403 otherwise)
DELETE /api/trips/:id/members/:userId 204 — owner only (owner cannot remove self)

join_code: 8 chars, same alphabet as account tokens, generated at trip creation, displayed grouped XXXX-XXXX. Members see it in trip JSON (it's how they invite others); it is NOT a credential — it only grants membership of that one trip.

Entries

Method & path Body Response
POST /api/trips/:id/entries {date, end_date?, type, title, details?, start_time?, end_time?, location_name?, lat?, lng?, sort_order?, price?, paid_by?, split_mode?, participants?, segments?} 201 {entry}; validates type enum, date format, end_date null or valid date ≥ date, title non-empty ≤200 chars; lat/lng must both be present or both absent, lat ∈ [-90,90], lng ∈ [-180,180]; price null or number ≥ 0; paid_by null or a trip member's user id; split_mode ∈ equal|own|payer (payer requires paid_by); participants null/[] (= all members) or array of trip-member user ids; segments per "Flight segments" above; transport_mode per "Transport entries" above (POSTing a stay may auto-create a transport entry, see same section)
PATCH /api/entries/:id any subset of the above 200 {entry} (member of the entry's trip required; participants replaces the whole set)
DELETE /api/entries/:id 204 (also deletes its entry_participants rows)

Entry JSON shape (always full row): {id, trip_id, date, end_date, type, title, details, start_time, end_time, location_name, lat, lng, sort_order, price, paid_by, split_mode, participants, segments, rental, transport_mode, auto_ref, waypoints} where participants is an array of user ids ([] = all members), segments is the parsed array or null, auto_ref is the parsed {from,to} object or null, and waypoints is the parsed [{lat,lng,name?}] array or null. auto_ref is server-managed (not accepted in POST/PATCH bodies; editing an auto transport keeps its auto_ref).

Checklist (packing list & advice)

Each trip has one flat checklist whose items are grouped by a free-text category ("Documents", "Clothing", …). Items are either shared (user_id null — every member sees and can tick them, e.g. "First-aid kit") or personal (user_id = the owner — only that user sees them, e.g. their own medication). There is no separate list entity: category is the grouping.

GET /api/trips/:id/checklist200

{
  "items": [
    { "id": 7, "trip_id": 3, "text": "Passport (valid 6+ months)", "category": "Documents",
      "qty": null, "checked": true, "checked_by": 1, "personal": false,
      "user_id": null, "sort_order": 0, "suggestion_key": "doc-passport" },
    { "id": 9, "trip_id": 3, "text": "T-shirts", "category": "Clothing",
      "qty": 8, "checked": false, "checked_by": null, "personal": true,
      "user_id": 1, "sort_order": 1, "suggestion_key": "clothing-tshirts" }
  ],
  "progress": { "total": 2, "checked": 1, "byCategory": [ { "category": "Documents", "total": 1, "checked": 1 } ] }
}
  • Returns shared items plus the caller's own personal items — never another member's personal items.
  • Item JSON is always the full row plus the derived boolean personal (user_id !== null); checked is a real boolean (not 0/1) and qty/checked_by/suggestion_key are null when unset.
  • Order: by category first (fixed order Documents, Clothing, Toiletries, Health, Electronics, Extras, then any other category alphabetically), then (sort_order, id).
  • progress.byCategory follows the same category order and covers only categories present in items.
Method & path Body Response
POST /api/trips/:id/checklist {text, category?, qty?, personal?, checked?, sort_order?} 201 {item} — validates: text non-empty ≤120 chars after trim; category ≤40 chars after trim (default General); qty null or integer 199; personal boolean (default false) → sets user_id to the caller; checked boolean (default false); sort_order integer (default: max within the trip's list + 1). Member of the trip required (else 404).
PATCH /api/checklist/:itemId any subset of {text, category, qty, checked, personal, sort_order} 200 {item} — same validation. Ticking (checked: true) sets checked_by to the caller; checked: false clears it. personal: true claims the item for the caller (user_id = caller), personal: false makes it shared (user_id null).
DELETE /api/checklist/:itemId 204
POST /api/trips/:id/checklist/reset {} 200 {unchecked: <count>} — unticks every item visible to the caller (shared + own personal) and clears their checked_by. For re-using a list on the return trip.

Item routes resolve the trip via the item, then require membership. A non-existent item, an item in a trip the caller is not a member of, or another user's personal item all return 404 {"error":"not found"} (no leaking).

Packing advice (suggestions) — deterministic, offline rules derived from the trip itself (duration, months, entry types, destination latitudes/countries). No external service, no LLM.

GET /api/trips/:id/checklist/suggestions200

{
  "suggestions": [
    { "key": "doc-passport", "text": "Passport (valid 6+ months)", "category": "Documents",
      "qty": null, "reason": "You have 2 flights", "added": true },
    { "key": "clothing-tshirts", "text": "T-shirts", "category": "Clothing",
      "qty": 8, "reason": "9 nights", "added": false }
  ],
  "context": { "days": 10, "nights": 9, "months": [8], "countries": ["Thailand"],
               "climate": ["tropical"], "flights": 2, "rentals": 1, "transportModes": ["ferry"] }
}
  • key is a stable identifier (kebab-case) — it is what POST takes and what gets stored in checklist_items.suggestion_key.
  • added = an item with that suggestion_key already exists among the items visible to the caller (shared or own personal), so the UI can grey it out.
  • reason is a short human string explaining why it was suggested ("9 nights", "Ferry crossing", "Thailand uses type A/B/C sockets"). Suggestions are ordered by category (same fixed order as items).
  • Rules live in src/server/util/packing.js as a pure buildSuggestions({trip, entries, days, nights}) so they are unit-testable and stable across calls with the same input. Rough rule set: always-on basics (ID/passport, cards+cash, medication, toothbrush, phone+charger, water bottle, day bag); nights-scaled clothing quantities (qty = min(nights + 1, 10) for t-shirts/underwear/socks, laundry kit over 7 nights); flight entries → liquids ≤100 ml, power bank in cabin, check-in done, plus neck pillow/compression socks on long-haul (any air leg > 5000 km); rental entries → driving licence, international driving permit, phone mount; transport modes → ferry ⇒ motion-sickness tablets, train ⇒ snacks + luggage lock; destination latitude/month → tropical (|lat| < 23.5) ⇒ sun cream, insect repellent, rain jacket, rehydration salts; cold (|lat| > 55 year-round, or |lat| ≥ 35 when the trip falls in that hemisphere's winter — the latitude floor stops a December trip to Bangkok being tagged both tropical and cold) ⇒ warm layers, hat + gloves; ≥3 stays ⇒ packing cubes; destination country → power-adapter suggestion naming the socket types (small built-in country table; unknown/mixed ⇒ "Universal travel adapter"). Countries come from the last comma-segment of location_name and from flight-segment airport codes. For flights only the arrival airport of each segment counts (for both climate and country): the first segment's departure airport is where the traveller starts out, not a destination — counting it would put "warm layers, hat & gloves" and a home-country plug adapter on a December Frankfurt→Bangkok beach trip. Flying back into a cold place later is still covered, since that arrival is a segment's to.

POST /api/trips/:id/checklist/suggestions {keys: ["doc-passport", …], personal?: false}201 {created: [item…], skipped: ["doc-passport"]}

  • Bulk-adds the given suggestions as checklist items (unchecked, suggestion_key set, appended in the current suggestion order). keys must be a non-empty array of ≤60 strings; an unknown key → 400 {"error":"unknown suggestion key: <key>"}.
  • Keys already present among the caller's visible items go to skipped instead of being duplicated.

Route & summary (computed)

GET /api/trips/:id/route

{
  "stops": [ {"entryId": 1, "date": "2026-08-01", "type": "flight",
              "title": "BKK → CNX", "location_name": "Chiang Mai", "lat": 18.79, "lng": 98.98} ],
  "legs":  [ {"fromEntryId": 1, "toEntryId": 4, "km": 587.3, "mode": "ground",
              "waypoints": [{"lat": 46.5, "lng": 10.45, "name": "Stelvio Pass"}]} ],
  "totalKm": 587.3,
  "summary": {
    "days": 10, "nights": 9,
    "flights": 2, "flightSegments": 4, "transports": 1, "activities": 4,
    "rentals": 1, "includedKm": 1500,
    "stays": 2, "areas": [{"name": "Venice", "days": 3}, {"name": "Berlin", "days": 7}],
    "kmAir": 0, "kmDriven": 587.3,
    "locations": ["Bangkok", "Chiang Mai"]
  }
}
  • stops = entries having lat/lng, ordered by (date, sort_order, id); flight entries with coordinate-bearing segments are expanded into airport stops instead (see "Flight segments" — kind: "airport", includes code). Stops also carry transport_mode (the entry's value, or null) so the map can show mode-specific icons.
  • legs = consecutive stop pairs; skip zero-distance pairs (< 0.05 km) — still include the stop, just no leg.
  • Each ground leg carries waypoints: the via-points of the transport entry that bridges it, or []. Bridging entry = the earliest (by date, id) transport entry with a non-empty waypoints array whose date falls within [fromStop.date, toStop.date] inclusive. Air legs always have waypoints: []. The map passes these to the directions proxy's via so the drawn road route detours through them; km in /route stays great-circle regardless (the routed distance is computed client-side).
  • Every leg has mode: "air" when BOTH endpoints are kind:"airport" stops expanded from the SAME flight entry (i.e. actual flight segments); "ground" for everything else (stay→airport transfers, city-to-city drives). summary.kmAir / summary.kmDriven are the per-mode sums (1 decimal). kmDriven is the rough rental-car figure: great-circle, so real road km will be somewhat higher.
  • days = inclusive count from start_date to end_date; nights = days - 1 (0 for single-day trips).
  • locations = unique location_name values in stop order.
  • km rounded to 1 decimal.

Costs & splitting (computed)

GET /api/trips/:id/costs

{
  "currency": "USD",
  "totalCost": 1450.0,
  "byType": { "flight": 800.0, "transport": 300.0, "stay": 350.0 },
  "perUser": [
    { "userId": 1, "displayName": "brave-otter", "share": 725.0, "paid": 950.0, "net": 225.0 },
    { "userId": 2, "displayName": "calm-heron",  "share": 725.0, "paid": 500.0, "net": -225.0 }
  ],
  "settlements": [ { "fromUserId": 2, "toUserId": 1, "amount": 225.0 } ],
  "unassigned": 0.0
}

Computation (see Cost semantics above):

  • share = what the user's part of the trip costs; paid = what they actually paid; net = paid - share (positive → others owe them).
  • equal with paid_by null: the cost still counts into shares and byType, but nobody is credited as payer — accumulate that amount into unassigned (frontend shows a hint to assign payers).
  • own: adds price to each participant's share AND paid (self-paid, no debt).
  • settlements: minimal-transfer greedy — repeatedly match the largest debtor with the largest creditor until all nets are settled; amounts rounded to 2 decimals, drop transfers < 0.01.
  • totalCost = sum of effective totals of all priced entries; byType groups the same by entry type.
  • All money values rounded to 2 decimals in the response.

Geocoding proxy

GET /api/geocode?q=<query>200 {results: [{name, lat, lng}]} (max 5).

Proxies https://nominatim.openstreetmap.org/search?format=jsonv2&limit=5&accept-language=en&q=… server-side with header User-Agent: trip-plan-app/0.1 (self-hosted) (accept-language=en so results come back in Latin script, not the local language). Map Nominatim's display_namename, parse lat/lon to numbers. On upstream failure return 502 {"error":"geocoding unavailable"}. Cache identical queries in-memory for 10 minutes.

Directions proxy (OSRM)

GET /api/directions?from=<lat>,<lng>&to=<lat>,<lng>[&via=<lat>,<lng>|<lat>,<lng>…]200 {km, geometry} (requires auth).

  • Optional via = up to 8 lat,lng pairs separated by |, routed in order between from and to (scenic waypoints). Each pair validated like from/to → any malformed/out-of-range pair or more than 8 → 400. The cache key includes the via-points.
  • Proxies ${OSRM_URL}/route/v1/driving/{fromLng},{fromLat};{viaLng},{viaLat};…;{toLng},{toLat}?overview=full&geometries=geojson server-side. OSRM_URL env var, default https://router.project-osrm.org (the public demo server — fine for light personal use; self-hosters can point it at their own OSRM). Send the same User-Agent header as the geocode proxy.
  • Validation: from/to must each be lat,lng with finite numbers in range (lat [-90,90], lng [-180,180]) → else 400. Upstream failure, non-Ok OSRM code, or no route → 502 {"error":"directions unavailable"}.
  • Response km = route distance / 1000 rounded to 1 decimal; geometry = the GeoJSON coordinates converted to [[lat,lng], …] (Leaflet order).
  • In-memory cache: key = both coord pairs rounded to 5 decimals, TTL 24 h, cap ~500 entries (evict oldest).

Consumer contract (map): the frontend requests directions for each mode: "ground" route leg and, on success, draws the road-following polyline instead of the straight line and shows the routed km for that leg ("via road"); on 502/failure it keeps the straight great-circle line silently. Air legs stay straight/dashed. /route itself (and summary.kmDriven) remains great-circle — the server does not call OSRM during route computation.

Frontend contract notes

  • SPA served from public/; all non-/api GETs fall back to public/index.html is NOT required — a single index.html with hash-based routing (#/login, #/trips, #/trip/:id) is the expected design, so no server-side fallback is needed.
  • Leaflet 1.9.x via unpkg CDN in index.html.
  • Session cookie is httpOnly; frontend detects auth state via GET /api/auth/me on load.
  • Checklist UI lives in public/js/views/checklist.js, rendered as a card in the trip detail side column (below Costs). It shows a progress bar, items grouped by category with a checkbox / qty / 🔒-personal marker per row, inline add, drag-reorder (dragdrop.js enableReorder, desktop-only like the rest), an "Uncheck all" action, and a "💡 Suggestions" modal listing the advice with per-item checkboxes and "Add selected". Ticking a box PATCHes optimistically and re-syncs on failure.