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.
32 KiB
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-DDstrings. Times:HH:MM24h strings (optional fields). - Auth: cookie session (
cookie-session). All endpoints exceptaccount(create) /loginrequire auth → otherwise401 {"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_namefor humans. - Trip access: user must be a member of the trip → otherwise
404 {"error":"not found"}(don't leak existence). - Errors:
4xx/5xxwith body{"error": "<human readable message>"}. - IDs are integers.
Server layout
src/server/app.js— builds and exports the Express app (export function createApp(dbPath)andexport defaulta ready app is fine, butcreateAppmust exist for tests).src/server/index.js— reads env (PORTdefault 3000,DATA_DIRdefault./data,SESSION_SECRETdefault dev value with console warning), ensures DATA_DIR exists, starts listener, servespublic/statically.src/server/db.js—better-sqlite3connection + schema creation (idempotentCREATE TABLE IF NOT EXISTS).src/server/routes/—auth.js,trips.js,entries.js,geocode.js,checklist.js.src/server/util/distance.js—haversineKm(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 type ∈ flight | 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_mode ∈ train | 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 0–8 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-transports — POST /api/trips/:id/transports/regenerate (any member, no body) reconciles auto transports after stays have been moved/reshuffled:
- 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_refnull) or any flight. - Existing auto transports (
type='transport',auto_refnon-null) whose{from,to}matches a desired pair are kept but re-synced:dateset 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. - 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
dateatstart_time, arr onend_dateatend_time. stayentries ("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 toend_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 1–8 objects; each needs from and to with code (2–4 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
pricecount toward costs. - Effective participants of an entry = its
entry_participantsrows, or all current trip members if it has none. split_mode:equal—priceis the TOTAL; split equally among effective participants ("rental car 50/50").paid_byis credited with having paid the total.own—priceis 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_byowes and pays the whole price alone (requirespaid_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 1–40 chars after trim |
Token rules:
- 16 chars from the unambiguous alphabet
ABCDEFGHJKMNPQRSTUVWXYZ23456789(no I/L/O/0/1), generated withcrypto.randomBytes, displayed groupedXXXX-XXXX-XXXX-XXXX. - Login normalizes input: strip dashes/spaces, uppercase, then compare
sha256(token)againstusers.token_hash. Only the hash is stored. display_nameis auto-generated at account creation asadjective-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/checklist → 200
{
"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);checkedis a real boolean (not 0/1) andqty/checked_by/suggestion_keyarenullwhen unset. - Order: by
categoryfirst (fixed orderDocuments, Clothing, Toiletries, Health, Electronics, Extras, then any other category alphabetically), then(sort_order, id). progress.byCategoryfollows the same category order and covers only categories present initems.
| 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 1–99; 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/suggestions → 200
{
"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"] }
}
keyis a stable identifier (kebab-case) — it is whatPOSTtakes and what gets stored inchecklist_items.suggestion_key.added= an item with thatsuggestion_keyalready exists among the items visible to the caller (shared or own personal), so the UI can grey it out.reasonis 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.jsas a purebuildSuggestions({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 oflocation_nameand 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'sto.
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_keyset, appended in the current suggestion order).keysmust 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
skippedinstead 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", includescode). Stops also carrytransport_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 (bydate,id)transportentry with a non-emptywaypointsarray whosedatefalls within[fromStop.date, toStop.date]inclusive. Air legs always havewaypoints: []. The map passes these to the directions proxy'sviaso the drawn road route detours through them;kmin/routestays great-circle regardless (the routed distance is computed client-side). - Every leg has
mode:"air"when BOTH endpoints arekind:"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.kmDrivenare the per-mode sums (1 decimal).kmDrivenis 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= uniquelocation_namevalues 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).equalwithpaid_bynull: the cost still counts into shares andbyType, but nobody is credited as payer — accumulate that amount intounassigned(frontend shows a hint to assign payers).own: addspriceto each participant'sshareANDpaid(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;byTypegroups 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_name→name, 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 8lat,lngpairs separated by|, routed in order betweenfromandto(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=geojsonserver-side.OSRM_URLenv var, defaulthttps://router.project-osrm.org(the public demo server — fine for light personal use; self-hosters can point it at their own OSRM). Send the sameUser-Agentheader as the geocode proxy. - Validation:
from/tomust each belat,lngwith finite numbers in range (lat [-90,90], lng [-180,180]) → else400. 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-/apiGETs fall back topublic/index.htmlis NOT required — a singleindex.htmlwith 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/meon 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.jsenableReorder, 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.