Add scenic waypoints for drive legs (OSRM via-routing)
- Transport entries carry an optional ordered waypoints array of lat/lng/name points; /route attaches them to the ground leg the transport bridges, and /api/directions accepts a via param so the drawn road route detours through them - Day editor gains a geocoded "Scenic waypoints" list on transport entries; the map draws leg-coloured waypoint dots - Escape waypoint names in the Leaflet tooltip (stored-XSS fix flagged by security review: names are user-typed and Leaflet renders string tooltips as HTML)
This commit is contained in:
+12
-4
@@ -49,6 +49,7 @@ entries (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id),
|
||||
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))
|
||||
@@ -63,6 +64,10 @@ Entry `type` ∈ `flight | transport | activity | rental | stay | note`.
|
||||
|
||||
`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:
|
||||
@@ -180,7 +185,7 @@ User JSON shape everywhere: `{id, display_name}`.
|
||||
| `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}` where `participants` is an array of user ids (`[]` = all members), `segments` is the parsed array or `null`, and `auto_ref` is the parsed `{from,to}` object or `null`. `auto_ref` is server-managed (not accepted in POST/PATCH bodies; editing an auto transport keeps its `auto_ref`).
|
||||
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`).
|
||||
|
||||
### Route & summary (computed)
|
||||
|
||||
@@ -190,7 +195,8 @@ Entry JSON shape (always full row): `{id, trip_id, date, end_date, type, title,
|
||||
{
|
||||
"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"} ],
|
||||
"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,
|
||||
@@ -205,6 +211,7 @@ Entry JSON shape (always full row): `{id, trip_id, date, end_date, type, title,
|
||||
|
||||
- `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.
|
||||
@@ -245,9 +252,10 @@ Proxies `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=5&accept
|
||||
|
||||
### Directions proxy (OSRM)
|
||||
|
||||
`GET /api/directions?from=<lat>,<lng>&to=<lat>,<lng>` → `200 {km, geometry}` (requires auth).
|
||||
`GET /api/directions?from=<lat>,<lng>&to=<lat>,<lng>[&via=<lat>,<lng>|<lat>,<lng>…]` → `200 {km, geometry}` (requires auth).
|
||||
|
||||
- Proxies `${OSRM_URL}/route/v1/driving/{fromLng},{fromLat};{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.
|
||||
- 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).
|
||||
|
||||
Reference in New Issue
Block a user