Files
trip-plan/docs/API.md
T
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

342 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.js``better-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.js``haversineKm(lat1, lng1, lat2, lng2)` returns km (number).
## Data model (SQLite)
```sql
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 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-transports**`POST /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.
```json
"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).
```json
"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`:
- `equal``price` is the TOTAL; split equally among effective participants ("rental car 50/50"). `paid_by` is credited with having paid the total.
- `own``price` 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/checklist``200`
```json
{
"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/suggestions``200`
```json
{
"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`
```json
{
"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`
```json
{
"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_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 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.