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)
|
rental TEXT, -- JSON object, rental entries only (see below)
|
||||||
transport_mode TEXT, -- transport 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)
|
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)
|
created_at TEXT DEFAULT current_timestamp)
|
||||||
entry_participants (entry_id INTEGER REFERENCES entries(id), user_id INTEGER REFERENCES users(id),
|
entry_participants (entry_id INTEGER REFERENCES entries(id), user_id INTEGER REFERENCES users(id),
|
||||||
PRIMARY KEY (entry_id, user_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).
|
`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.
|
**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:
|
**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) |
|
| `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) |
|
| `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)
|
### 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",
|
"stops": [ {"entryId": 1, "date": "2026-08-01", "type": "flight",
|
||||||
"title": "BKK → CNX", "location_name": "Chiang Mai", "lat": 18.79, "lng": 98.98} ],
|
"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,
|
"totalKm": 587.3,
|
||||||
"summary": {
|
"summary": {
|
||||||
"days": 10, "nights": 9,
|
"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.
|
- `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.
|
- `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.
|
- 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).
|
- `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.
|
- `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)
|
### 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"}`.
|
- 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).
|
- 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).
|
- In-memory cache: key = both coord pairs rounded to 5 decimals, TTL 24 h, cap ~500 entries (evict oldest).
|
||||||
|
|||||||
@@ -284,6 +284,8 @@ textarea.input { resize: vertical; }
|
|||||||
.map-pin-wrap { background: none; border: none; }
|
.map-pin-wrap { background: none; border: none; }
|
||||||
.map-pin { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 50%; color: #fff; font-weight: 700; font-size: 0.8rem; box-shadow: 0 2px 6px rgba(0,0,0,0.35); border: 2px solid #fff; }
|
.map-pin { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 50%; color: #fff; font-weight: 700; font-size: 0.8rem; box-shadow: 0 2px 6px rgba(0,0,0,0.35); border: 2px solid #fff; }
|
||||||
.km-label { background: rgba(37, 99, 235, 0.92); color: #fff; font-size: 0.7rem; font-weight: 700; padding: 0.1rem 0.4rem 0.1rem 0.55rem; border-radius: 999px; white-space: nowrap; box-shadow: 0 1px 3px rgba(0,0,0,0.3); border-left: 4px solid var(--leg-accent, transparent); }
|
.km-label { background: rgba(37, 99, 235, 0.92); color: #fff; font-size: 0.7rem; font-weight: 700; padding: 0.1rem 0.4rem 0.1rem 0.55rem; border-radius: 999px; white-space: nowrap; box-shadow: 0 1px 3px rgba(0,0,0,0.3); border-left: 4px solid var(--leg-accent, transparent); }
|
||||||
|
.wp-dot-wrap { background: none; border: none; }
|
||||||
|
.wp-dot { display: block; width: 10px; height: 10px; border-radius: 50%; background: var(--wp-color, var(--brand)); border: 2px solid #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.35); }
|
||||||
.map-popup strong { font-size: 0.95rem; }
|
.map-popup strong { font-size: 0.95rem; }
|
||||||
.map-popup-sub { color: #64748b; font-size: 0.8rem; margin-top: 0.15rem; }
|
.map-popup-sub { color: #64748b; font-size: 0.8rem; margin-top: 0.15rem; }
|
||||||
.map-popup-loc { font-size: 0.82rem; margin-top: 0.15rem; }
|
.map-popup-loc { font-size: 0.82rem; margin-top: 0.15rem; }
|
||||||
@@ -326,6 +328,8 @@ textarea.input { resize: vertical; }
|
|||||||
.icon-btn { border: none; background: var(--surface-2); border-radius: 8px; width: 2rem; height: 2rem; cursor: pointer; font-size: 1rem; display: inline-flex; align-items: center; justify-content: center; color: var(--text-muted); }
|
.icon-btn { border: none; background: var(--surface-2); border-radius: 8px; width: 2rem; height: 2rem; cursor: pointer; font-size: 1rem; display: inline-flex; align-items: center; justify-content: center; color: var(--text-muted); }
|
||||||
.icon-btn:hover { background: var(--border); color: var(--text); }
|
.icon-btn:hover { background: var(--border); color: var(--text); }
|
||||||
.icon-btn.danger:hover { background: var(--danger-soft); color: var(--danger); }
|
.icon-btn.danger:hover { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
.icon-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.icon-btn:disabled:hover { background: var(--surface-2); color: var(--text-muted); }
|
||||||
|
|
||||||
.entry-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
.entry-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
.entry-empty { padding: 0.6rem 0; }
|
.entry-empty { padding: 0.6rem 0; }
|
||||||
@@ -434,6 +438,17 @@ textarea.input { resize: vertical; }
|
|||||||
.entry-seg { display: flex; gap: 0.5rem; font-size: 0.8rem; font-variant-numeric: tabular-nums; }
|
.entry-seg { display: flex; gap: 0.5rem; font-size: 0.8rem; font-variant-numeric: tabular-nums; }
|
||||||
.seg-flight { font-weight: 700; color: var(--brand-dark); }
|
.seg-flight { font-weight: 700; color: var(--brand-dark); }
|
||||||
|
|
||||||
|
/* ---------- Scenic waypoints (day editor) ---------- */
|
||||||
|
.wp-section { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.wp-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.wp-row { display: flex; align-items: center; gap: 0.5rem; border: 1px solid var(--border); border-left: 3px solid var(--brand); border-radius: var(--radius-sm); padding: 0.45rem 0.6rem; background: var(--surface-2); }
|
||||||
|
.wp-num { flex-shrink: 0; width: 1.4rem; height: 1.4rem; display: inline-flex; align-items: center; justify-content: center; background: var(--brand-soft); color: var(--brand-dark); border-radius: 50%; font-weight: 700; font-size: 0.75rem; }
|
||||||
|
.wp-name { flex: 1; font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.wp-row-actions { display: flex; gap: 0.2rem; flex-shrink: 0; }
|
||||||
|
.wp-move { font-size: 0.8rem; }
|
||||||
|
.wp-search { position: relative; }
|
||||||
|
.wp-max-hint { margin: 0; }
|
||||||
|
|
||||||
.stat-sub { font-size: 0.66rem; color: var(--brand-dark); font-weight: 700; }
|
.stat-sub { font-size: 0.66rem; color: var(--brand-dark); font-weight: 700; }
|
||||||
|
|
||||||
/* ---------- Rental details (day editor) ---------- */
|
/* ---------- Rental details (day editor) ---------- */
|
||||||
|
|||||||
+4
-1
@@ -73,7 +73,10 @@ export const api = {
|
|||||||
costs: (id) => get(`/api/trips/${id}/costs`),
|
costs: (id) => get(`/api/trips/${id}/costs`),
|
||||||
regenerateTransports: (id) => post(`/api/trips/${id}/transports/regenerate`, {}),
|
regenerateTransports: (id) => post(`/api/trips/${id}/transports/regenerate`, {}),
|
||||||
},
|
},
|
||||||
directions: (from, to) => get(`/api/directions?from=${from.lat},${from.lng}&to=${to.lat},${to.lng}`),
|
directions: (from, to, via = []) => get(
|
||||||
|
`/api/directions?from=${from.lat},${from.lng}&to=${to.lat},${to.lng}` +
|
||||||
|
(via.length ? `&via=${via.map((w) => `${w.lat},${w.lng}`).join('|')}` : ''),
|
||||||
|
),
|
||||||
entries: {
|
entries: {
|
||||||
create: (tripId, payload) => post(`/api/trips/${tripId}/entries`, payload),
|
create: (tripId, payload) => post(`/api/trips/${tripId}/entries`, payload),
|
||||||
update: (id, patchBody) => patch(`/api/entries/${id}`, patchBody),
|
update: (id, patchBody) => patch(`/api/entries/${id}`, patchBody),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from '../format.js';
|
} from '../format.js';
|
||||||
import { createFlightRoute, renderSegmentLines } from './segments.js';
|
import { createFlightRoute, renderSegmentLines } from './segments.js';
|
||||||
import { createRentalDetails, renderRentalLine } from './rental.js';
|
import { createRentalDetails, renderRentalLine } from './rental.js';
|
||||||
|
import { createWaypoints } from './waypoints.js';
|
||||||
import { createCostForm } from './costForm.js';
|
import { createCostForm } from './costForm.js';
|
||||||
import { enableRowReorder } from './dragdrop.js';
|
import { enableRowReorder } from './dragdrop.js';
|
||||||
|
|
||||||
@@ -190,6 +191,7 @@ export function openDayEditor(tctx, date) {
|
|||||||
// Flight segments (load() triggers the flight-route onChange -> UI sync).
|
// Flight segments (load() triggers the flight-route onChange -> UI sync).
|
||||||
fields.flightRoute.load(Array.isArray(entry.segments) ? entry.segments : []);
|
fields.flightRoute.load(Array.isArray(entry.segments) ? entry.segments : []);
|
||||||
fields.rentalDetails.load(entry.rental || null);
|
fields.rentalDetails.load(entry.rental || null);
|
||||||
|
fields.waypoints.load(Array.isArray(entry.waypoints) ? entry.waypoints : []);
|
||||||
renderLoc();
|
renderLoc();
|
||||||
panel.querySelector('.entry-form').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
panel.querySelector('.entry-form').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
fields.title.focus();
|
fields.title.focus();
|
||||||
@@ -243,6 +245,10 @@ export function openDayEditor(tctx, date) {
|
|||||||
// state), before the section elements below exist — gate until wired.
|
// state), before the section elements below exist — gate until wired.
|
||||||
let typeUIReady = false;
|
let typeUIReady = false;
|
||||||
const flightRoute = createFlightRoute({ onChange: () => { if (typeUIReady) syncTypeUI(); } });
|
const flightRoute = createFlightRoute({ onChange: () => { if (typeUIReady) syncTypeUI(); } });
|
||||||
|
|
||||||
|
// ----- Scenic waypoints subsection (shown only for transport entries) -----
|
||||||
|
const waypoints = createWaypoints({ onChange: () => { if (typeUIReady) syncTypeUI(); } });
|
||||||
|
const waypointsSection = waypoints.node;
|
||||||
const flightSection = el(
|
const flightSection = el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'flight-section' },
|
{ class: 'flight-section' },
|
||||||
@@ -269,6 +275,7 @@ export function openDayEditor(tctx, date) {
|
|||||||
flightSection.style.display = type === 'flight' ? '' : 'none';
|
flightSection.style.display = type === 'flight' ? '' : 'none';
|
||||||
rentalSection.style.display = type === 'rental' ? '' : 'none';
|
rentalSection.style.display = type === 'rental' ? '' : 'none';
|
||||||
modeField.style.display = type === 'transport' ? '' : 'none';
|
modeField.style.display = type === 'transport' ? '' : 'none';
|
||||||
|
waypointsSection.style.display = type === 'transport' ? '' : 'none';
|
||||||
locationField.style.display = type === 'flight' && flightRoute.hasSegments() ? 'none' : '';
|
locationField.style.display = type === 'flight' && flightRoute.hasSegments() ? 'none' : '';
|
||||||
// Stays emphasise an end date ("until"); other types call it "End date".
|
// Stays emphasise an end date ("until"); other types call it "End date".
|
||||||
endDateLabel.textContent = type === 'stay' ? 'Until' : 'End date';
|
endDateLabel.textContent = type === 'stay' ? 'Until' : 'End date';
|
||||||
@@ -285,7 +292,7 @@ export function openDayEditor(tctx, date) {
|
|||||||
type: typeSelect, title: titleInput, details: detailsInput, start: startInput, end: endInput,
|
type: typeSelect, title: titleInput, details: detailsInput, start: startInput, end: endInput,
|
||||||
endDate: endDateInput, mode: modeSelect,
|
endDate: endDateInput, mode: modeSelect,
|
||||||
locInput, locResults, locSelected,
|
locInput, locResults, locSelected,
|
||||||
cost: costForm, flightRoute, rentalDetails,
|
cost: costForm, flightRoute, rentalDetails, waypoints,
|
||||||
};
|
};
|
||||||
|
|
||||||
wireGeocode(locInput, locResults);
|
wireGeocode(locInput, locResults);
|
||||||
@@ -354,6 +361,10 @@ export function openDayEditor(tctx, date) {
|
|||||||
// changing an entry's type away from transport drops any prior mode.
|
// changing an entry's type away from transport drops any prior mode.
|
||||||
payload.transport_mode = typeSelect.value === 'transport' ? (modeSelect.value || null) : null;
|
payload.transport_mode = typeSelect.value === 'transport' ? (modeSelect.value || null) : null;
|
||||||
|
|
||||||
|
// Scenic waypoints (transport entries only). Clear them otherwise so
|
||||||
|
// changing an entry's type away from transport drops any prior waypoints.
|
||||||
|
payload.waypoints = typeSelect.value === 'transport' ? waypoints.read().waypoints : null;
|
||||||
|
|
||||||
// end_date must be on/after the effective start date (rental may have
|
// end_date must be on/after the effective start date (rental may have
|
||||||
// moved payload.date to the pickup date above).
|
// moved payload.date to the pickup date above).
|
||||||
if (payload.end_date && payload.end_date < payload.date) {
|
if (payload.end_date && payload.end_date < payload.date) {
|
||||||
@@ -416,6 +427,7 @@ export function openDayEditor(tctx, date) {
|
|||||||
),
|
),
|
||||||
flightSection,
|
flightSection,
|
||||||
rentalSection,
|
rentalSection,
|
||||||
|
waypointsSection,
|
||||||
locationField,
|
locationField,
|
||||||
el('div', { class: 'cost-heading' }, 'Cost (optional)'),
|
el('div', { class: 'cost-heading' }, 'Cost (optional)'),
|
||||||
costForm.node,
|
costForm.node,
|
||||||
|
|||||||
+19
-1
@@ -114,6 +114,15 @@ function initMap(mapDiv, stops, legs, kmEls) {
|
|||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
const mid = [(a.lat + b.lat) / 2, (a.lng + b.lng) / 2];
|
const mid = [(a.lat + b.lat) / 2, (a.lng + b.lng) / 2];
|
||||||
const label = L.marker(mid, { icon: kmLabel(L, leg.km, false, color), interactive: false }).addTo(map);
|
const label = L.marker(mid, { icon: kmLabel(L, leg.km, false, color), interactive: false }).addTo(map);
|
||||||
|
// Scenic via-points (ground legs only) — small dots in the leg's colour,
|
||||||
|
// with the waypoint name as a tooltip.
|
||||||
|
(leg.waypoints || []).forEach((wp) => {
|
||||||
|
// esc() the name: Leaflet renders a string tooltip as HTML, and wp.name
|
||||||
|
// is user-typed, so a raw value is a stored-XSS sink for co-travellers.
|
||||||
|
L.marker([wp.lat, wp.lng], { icon: waypointIcon(L, color), title: wp.name || '' })
|
||||||
|
.bindTooltip(esc(wp.name || 'Waypoint'))
|
||||||
|
.addTo(map);
|
||||||
|
});
|
||||||
return { leg, a, b, line, label, color };
|
return { leg, a, b, line, label, color };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,7 +143,7 @@ function initMap(mapDiv, stops, legs, kmEls) {
|
|||||||
function fetchRoadGeometry(L, map, mapDiv, legLayers, kmEls) {
|
function fetchRoadGeometry(L, map, mapDiv, legLayers, kmEls) {
|
||||||
legLayers.forEach(({ leg, a, b, line, label, color }, i) => {
|
legLayers.forEach(({ leg, a, b, line, label, color }, i) => {
|
||||||
if (leg.mode !== 'ground') return;
|
if (leg.mode !== 'ground') return;
|
||||||
api.directions({ lat: a.lat, lng: a.lng }, { lat: b.lat, lng: b.lng })
|
api.directions({ lat: a.lat, lng: a.lng }, { lat: b.lat, lng: b.lng }, leg.waypoints || [])
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
// The trip view may have been re-rendered (refreshTrip) while this
|
// The trip view may have been re-rendered (refreshTrip) while this
|
||||||
// was in flight — the old map/section is detached from the DOM and
|
// was in flight — the old map/section is detached from the DOM and
|
||||||
@@ -177,6 +186,15 @@ function numberedIcon(L, n, color) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function waypointIcon(L, color) {
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'wp-dot-wrap',
|
||||||
|
html: `<span class="wp-dot" style="--wp-color:${color}"></span>`,
|
||||||
|
iconSize: [10, 10],
|
||||||
|
iconAnchor: [5, 5],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function kmLabel(L, km, road, color) {
|
function kmLabel(L, km, road, color) {
|
||||||
const accent = color ? ` style="--leg-accent:${color}"` : '';
|
const accent = color ? ` style="--leg-accent:${color}"` : '';
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// Scenic waypoints editor used inside the day editor for transport entries.
|
||||||
|
// Geocode-backed search (mirrors the day editor's location field) to append
|
||||||
|
// ordered via-points, each removable and reorderable with up/down buttons.
|
||||||
|
// Kept as its own module so dayEditor.js stays small.
|
||||||
|
import { el, clear, toast } from '../dom.js';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
|
||||||
|
const MAX_WAYPOINTS = 8;
|
||||||
|
|
||||||
|
// createWaypoints({ initialWaypoints, onChange }) -> { node, read(), load(), hasWaypoints() }
|
||||||
|
// read() returns { waypoints: [...] } — an array, possibly empty.
|
||||||
|
export function createWaypoints({ initialWaypoints = null, onChange = () => {} } = {}) {
|
||||||
|
const points = []; // { lat, lng, name }
|
||||||
|
|
||||||
|
const listEl = el('div', { class: 'wp-list' });
|
||||||
|
const searchInput = el('input', {
|
||||||
|
class: 'input',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Search a scenic detour (OpenStreetMap)…',
|
||||||
|
autocomplete: 'off',
|
||||||
|
});
|
||||||
|
const resultsBox = el('div', { class: 'loc-results' });
|
||||||
|
const searchField = el('div', { class: 'wp-search' }, searchInput, resultsBox);
|
||||||
|
const maxHint = el('p', { class: 'hint muted wp-max-hint' }, 'Maximum of 8 waypoints reached.');
|
||||||
|
|
||||||
|
const node = el(
|
||||||
|
'div',
|
||||||
|
{ class: 'wp-section' },
|
||||||
|
el('div', { class: 'cost-heading' }, 'Scenic waypoints (optional)'),
|
||||||
|
el('p', { class: 'hint muted' }, 'Add via-points to route a drive through a scenic detour.'),
|
||||||
|
listEl,
|
||||||
|
searchField,
|
||||||
|
maxHint,
|
||||||
|
);
|
||||||
|
|
||||||
|
function renderRows() {
|
||||||
|
clear(listEl);
|
||||||
|
points.forEach((p, i) => {
|
||||||
|
const upBtn = el('button', {
|
||||||
|
class: 'icon-btn wp-move',
|
||||||
|
type: 'button',
|
||||||
|
title: 'Move up',
|
||||||
|
disabled: i === 0,
|
||||||
|
onClick: () => {
|
||||||
|
[points[i - 1], points[i]] = [points[i], points[i - 1]];
|
||||||
|
renderRows();
|
||||||
|
onChange();
|
||||||
|
},
|
||||||
|
}, '↑');
|
||||||
|
const downBtn = el('button', {
|
||||||
|
class: 'icon-btn wp-move',
|
||||||
|
type: 'button',
|
||||||
|
title: 'Move down',
|
||||||
|
disabled: i === points.length - 1,
|
||||||
|
onClick: () => {
|
||||||
|
[points[i + 1], points[i]] = [points[i], points[i + 1]];
|
||||||
|
renderRows();
|
||||||
|
onChange();
|
||||||
|
},
|
||||||
|
}, '↓');
|
||||||
|
const removeBtn = el('button', {
|
||||||
|
class: 'icon-btn danger',
|
||||||
|
type: 'button',
|
||||||
|
title: 'Remove waypoint',
|
||||||
|
onClick: () => {
|
||||||
|
points.splice(i, 1);
|
||||||
|
renderRows();
|
||||||
|
onChange();
|
||||||
|
},
|
||||||
|
}, '×');
|
||||||
|
listEl.appendChild(
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'wp-row' },
|
||||||
|
el('span', { class: 'wp-num' }, String(i + 1)),
|
||||||
|
el('span', { class: 'wp-name' }, p.name || `${p.lat.toFixed(4)}, ${p.lng.toFixed(4)}`),
|
||||||
|
el('div', { class: 'wp-row-actions' }, upBtn, downBtn, removeBtn),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
syncSearchVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSearchVisibility() {
|
||||||
|
const full = points.length >= MAX_WAYPOINTS;
|
||||||
|
searchField.style.display = full ? 'none' : '';
|
||||||
|
maxHint.style.display = full ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer = null;
|
||||||
|
let seq = 0;
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
const q = searchInput.value.trim();
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (q.length < 2) {
|
||||||
|
clear(resultsBox);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = setTimeout(async () => {
|
||||||
|
const mySeq = ++seq;
|
||||||
|
resultsBox.classList.add('loading');
|
||||||
|
try {
|
||||||
|
const data = await api.geocode(q);
|
||||||
|
if (mySeq !== seq) return; // a newer query superseded this one
|
||||||
|
showResults(data.results || []);
|
||||||
|
} catch (err) {
|
||||||
|
if (mySeq !== seq) return;
|
||||||
|
clear(resultsBox);
|
||||||
|
toast(err.message || 'Location search failed');
|
||||||
|
} finally {
|
||||||
|
resultsBox.classList.remove('loading');
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
function showResults(results) {
|
||||||
|
clear(resultsBox);
|
||||||
|
if (!results.length) {
|
||||||
|
resultsBox.appendChild(el('div', { class: 'loc-empty muted' }, 'No matches'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const r of results) {
|
||||||
|
resultsBox.appendChild(
|
||||||
|
el(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
class: 'loc-result',
|
||||||
|
type: 'button',
|
||||||
|
onClick: () => {
|
||||||
|
if (points.length >= MAX_WAYPOINTS) return;
|
||||||
|
points.push({ lat: r.lat, lng: r.lng, name: r.name });
|
||||||
|
searchInput.value = '';
|
||||||
|
clear(resultsBox);
|
||||||
|
renderRows();
|
||||||
|
onChange();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
el('span', { class: 'loc-result-name' }, r.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function read() {
|
||||||
|
return { waypoints: points.map((p) => ({ lat: p.lat, lng: p.lng, name: p.name || undefined })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace all waypoints (used when editing an existing transport entry).
|
||||||
|
function load(waypoints) {
|
||||||
|
points.length = 0;
|
||||||
|
if (Array.isArray(waypoints)) {
|
||||||
|
for (const w of waypoints) points.push({ lat: w.lat, lng: w.lng, name: w.name || '' });
|
||||||
|
}
|
||||||
|
renderRows();
|
||||||
|
onChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(initialWaypoints)) {
|
||||||
|
for (const w of initialWaypoints) points.push({ lat: w.lat, lng: w.lng, name: w.name || '' });
|
||||||
|
}
|
||||||
|
renderRows();
|
||||||
|
|
||||||
|
return { node, read, load, hasWaypoints: () => points.length > 0 };
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS entries (
|
|||||||
rental TEXT,
|
rental TEXT,
|
||||||
transport_mode TEXT,
|
transport_mode TEXT,
|
||||||
auto_ref TEXT,
|
auto_ref TEXT,
|
||||||
|
waypoints TEXT,
|
||||||
created_at TEXT DEFAULT current_timestamp
|
created_at TEXT DEFAULT current_timestamp
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ const MIGRATIONS = [
|
|||||||
{ table: 'entries', column: 'end_date', ddl: 'ALTER TABLE entries ADD COLUMN end_date TEXT' },
|
{ table: 'entries', column: 'end_date', ddl: 'ALTER TABLE entries ADD COLUMN end_date TEXT' },
|
||||||
{ table: 'entries', column: 'transport_mode', ddl: 'ALTER TABLE entries ADD COLUMN transport_mode TEXT' },
|
{ table: 'entries', column: 'transport_mode', ddl: 'ALTER TABLE entries ADD COLUMN transport_mode TEXT' },
|
||||||
{ table: 'entries', column: 'auto_ref', ddl: 'ALTER TABLE entries ADD COLUMN auto_ref TEXT' },
|
{ table: 'entries', column: 'auto_ref', ddl: 'ALTER TABLE entries ADD COLUMN auto_ref TEXT' },
|
||||||
|
{ table: 'entries', column: 'waypoints', ddl: 'ALTER TABLE entries ADD COLUMN waypoints TEXT' },
|
||||||
{ table: 'trip_members', column: 'sort_order', ddl: 'ALTER TABLE trip_members ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0' },
|
{ table: 'trip_members', column: 'sort_order', ddl: 'ALTER TABLE trip_members ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -20,27 +20,50 @@ function round1(n) {
|
|||||||
return Math.round(n * 10) / 10;
|
return Math.round(n * 10) / 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_VIA = 8;
|
||||||
|
|
||||||
|
// Parses the optional `via` query param: up to 8 "lat,lng" pairs separated by
|
||||||
|
// '|'. Returns { points: [...] } (points is [] when via is absent) or
|
||||||
|
// { error: true } when any pair is malformed/out of range or there are too many.
|
||||||
|
function parseVia(value) {
|
||||||
|
if (value === undefined) return { points: [] };
|
||||||
|
const parts = String(value).split('|');
|
||||||
|
if (parts.length > MAX_VIA) return { error: true };
|
||||||
|
const points = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
const point = parseLatLng(part);
|
||||||
|
if (!point) return { error: true };
|
||||||
|
points.push(point);
|
||||||
|
}
|
||||||
|
return { points };
|
||||||
|
}
|
||||||
|
|
||||||
export default function directionsRoutes() {
|
export default function directionsRoutes() {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const cache = new Map(); // "lat,lng;lat,lng" (rounded) -> { at, data }
|
const cache = new Map(); // "lat,lng;lat,lng;..." (rounded) -> { at, data }
|
||||||
|
|
||||||
// GET /api/directions?from=lat,lng&to=lat,lng
|
// GET /api/directions?from=lat,lng&to=lat,lng[&via=lat,lng|lat,lng...]
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const from = parseLatLng(req.query.from);
|
const from = parseLatLng(req.query.from);
|
||||||
const to = parseLatLng(req.query.to);
|
const to = parseLatLng(req.query.to);
|
||||||
if (!from || !to) {
|
if (!from || !to) {
|
||||||
return res.status(400).json({ error: 'from and to must be lat,lng' });
|
return res.status(400).json({ error: 'from and to must be lat,lng' });
|
||||||
}
|
}
|
||||||
|
const via = parseVia(req.query.via);
|
||||||
|
if (via.error) {
|
||||||
|
return res.status(400).json({ error: 'via must be up to 8 lat,lng pairs' });
|
||||||
|
}
|
||||||
|
|
||||||
const osrmUrl = process.env.OSRM_URL || 'https://router.project-osrm.org';
|
const osrmUrl = process.env.OSRM_URL || 'https://router.project-osrm.org';
|
||||||
const key = `${from.lat.toFixed(5)},${from.lng.toFixed(5)};${to.lat.toFixed(5)},${to.lng.toFixed(5)}`;
|
const coords = [from, ...via.points, to];
|
||||||
|
const key = coords.map((p) => `${p.lat.toFixed(5)},${p.lng.toFixed(5)}`).join(';');
|
||||||
|
|
||||||
const cached = cache.get(key);
|
const cached = cache.get(key);
|
||||||
if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
|
||||||
return res.status(200).json(cached.data);
|
return res.status(200).json(cached.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `${osrmUrl}/route/v1/driving/${from.lng},${from.lat};${to.lng},${to.lat}?overview=full&geometries=geojson`;
|
const url = `${osrmUrl}/route/v1/driving/${coords.map((p) => `${p.lng},${p.lat}`).join(';')}?overview=full&geometries=geojson`;
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
const upstream = await fetch(url, {
|
const upstream = await fetch(url, {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { membership } from '../util/access.js';
|
|||||||
import { ENTRY_COLUMNS, attachParticipants } from '../util/entrySerialize.js';
|
import { ENTRY_COLUMNS, attachParticipants } from '../util/entrySerialize.js';
|
||||||
import { validateSegments } from '../util/segments.js';
|
import { validateSegments } from '../util/segments.js';
|
||||||
import { validateRental } from '../util/rental.js';
|
import { validateRental } from '../util/rental.js';
|
||||||
|
import { validateWaypoints } from '../util/waypoints.js';
|
||||||
import { autoTransportTitle } from '../util/autoTransport.js';
|
import { autoTransportTitle } from '../util/autoTransport.js';
|
||||||
|
|
||||||
const ENTRY_TYPES = new Set(['flight', 'transport', 'activity', 'rental', 'stay', 'note']);
|
const ENTRY_TYPES = new Set(['flight', 'transport', 'activity', 'rental', 'stay', 'note']);
|
||||||
@@ -208,6 +209,22 @@ function validateEntry(body, { partial, existing, memberIds }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// waypoints: transport-only scenic via-points (null or [] clears them).
|
||||||
|
if (has('waypoints')) {
|
||||||
|
const v = body.waypoints;
|
||||||
|
if (v === null || (Array.isArray(v) && v.length === 0)) {
|
||||||
|
fields.waypoints = null;
|
||||||
|
} else {
|
||||||
|
const effType = 'type' in fields ? fields.type : existing?.type;
|
||||||
|
if (effType !== 'transport') {
|
||||||
|
return { error: 'waypoints are only allowed on transport entries' };
|
||||||
|
}
|
||||||
|
const checked = validateWaypoints(v);
|
||||||
|
if (checked.error) return { error: checked.error };
|
||||||
|
fields.waypoints = JSON.stringify(checked.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { fields, participants, hasParticipants: has('participants') };
|
return { fields, participants, hasParticipants: has('participants') };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,8 +323,8 @@ export default function entriesRoutes(db) {
|
|||||||
`INSERT INTO entries
|
`INSERT INTO entries
|
||||||
(trip_id, date, end_date, type, title, details, start_time, end_time,
|
(trip_id, date, end_date, type, title, details, start_time, end_time,
|
||||||
location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental,
|
location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental,
|
||||||
transport_mode)
|
transport_mode, waypoints)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
tripId,
|
tripId,
|
||||||
@@ -327,7 +344,8 @@ export default function entriesRoutes(db) {
|
|||||||
f.split_mode ?? 'equal',
|
f.split_mode ?? 'equal',
|
||||||
f.segments ?? null,
|
f.segments ?? null,
|
||||||
f.rental ?? null,
|
f.rental ?? null,
|
||||||
f.transport_mode ?? null
|
f.transport_mode ?? null,
|
||||||
|
f.waypoints ?? null
|
||||||
);
|
);
|
||||||
const id = Number(info.lastInsertRowid);
|
const id = Number(info.lastInsertRowid);
|
||||||
// participants provided as an array -> store rows; null/absent -> all members.
|
// participants provided as an array -> store rows; null/absent -> all members.
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import {
|
|||||||
attachParticipantsAll,
|
attachParticipantsAll,
|
||||||
parseSegments,
|
parseSegments,
|
||||||
parseRental,
|
parseRental,
|
||||||
|
parseWaypoints,
|
||||||
} from '../util/entrySerialize.js';
|
} from '../util/entrySerialize.js';
|
||||||
import { generateJoinCode, formatJoinCode, normalizeCode } from '../util/token.js';
|
import { generateJoinCode, formatJoinCode, normalizeCode } from '../util/token.js';
|
||||||
import { regenerateAutoTransports } from '../util/autoTransport.js';
|
import { regenerateAutoTransports } from '../util/autoTransport.js';
|
||||||
|
import { buildLegWaypointsResolver } from '../util/legWaypoints.js';
|
||||||
|
|
||||||
const MAX_RANGE_DAYS = 365;
|
const MAX_RANGE_DAYS = 365;
|
||||||
const MIN_LEG_KM = 0.05;
|
const MIN_LEG_KM = 0.05;
|
||||||
@@ -316,9 +318,11 @@ export default function tripsRoutes(db) {
|
|||||||
...r,
|
...r,
|
||||||
segments: parseSegments(r.segments),
|
segments: parseSegments(r.segments),
|
||||||
rental: parseRental(r.rental),
|
rental: parseRental(r.rental),
|
||||||
|
waypoints: parseWaypoints(r.waypoints),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const stops = buildStops(allEntries);
|
const stops = buildStops(allEntries);
|
||||||
|
const waypointsForLeg = buildLegWaypointsResolver(allEntries);
|
||||||
|
|
||||||
const legs = [];
|
const legs = [];
|
||||||
let totalKm = 0;
|
let totalKm = 0;
|
||||||
@@ -338,6 +342,7 @@ export default function tripsRoutes(db) {
|
|||||||
toEntryId: b.entryId,
|
toEntryId: b.entryId,
|
||||||
km: Math.round(km * 10) / 10,
|
km: Math.round(km * 10) / 10,
|
||||||
mode,
|
mode,
|
||||||
|
waypoints: isAir ? [] : waypointsForLeg(a.date, b.date),
|
||||||
});
|
});
|
||||||
totalKm += km;
|
totalKm += km;
|
||||||
if (isAir) kmAir += km;
|
if (isAir) kmAir += km;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
export const ENTRY_COLUMNS =
|
export const ENTRY_COLUMNS =
|
||||||
'id, trip_id, date, end_date, type, title, details, start_time, end_time, ' +
|
'id, trip_id, date, end_date, type, title, details, start_time, end_time, ' +
|
||||||
'location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental, transport_mode, auto_ref';
|
'location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental, transport_mode, auto_ref, waypoints';
|
||||||
|
|
||||||
// Parse the stored segments JSON text into an array, or null if absent/invalid.
|
// Parse the stored segments JSON text into an array, or null if absent/invalid.
|
||||||
export function parseSegments(value) {
|
export function parseSegments(value) {
|
||||||
@@ -41,6 +41,17 @@ export function parseAutoRef(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse the stored waypoints JSON text into an array, or null if absent/invalid.
|
||||||
|
export function parseWaypoints(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return Array.isArray(parsed) ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function attachParticipants(db, row) {
|
export function attachParticipants(db, row) {
|
||||||
if (!row) return row;
|
if (!row) return row;
|
||||||
const rows = db
|
const rows = db
|
||||||
@@ -50,6 +61,7 @@ export function attachParticipants(db, row) {
|
|||||||
row.segments = parseSegments(row.segments);
|
row.segments = parseSegments(row.segments);
|
||||||
row.rental = parseRental(row.rental);
|
row.rental = parseRental(row.rental);
|
||||||
row.auto_ref = parseAutoRef(row.auto_ref);
|
row.auto_ref = parseAutoRef(row.auto_ref);
|
||||||
|
row.waypoints = parseWaypoints(row.waypoints);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Attach scenic waypoints (see "Scenic waypoints" in docs/API.md) to route
|
||||||
|
// legs. A ground leg's waypoints come from the transport entry that bridges
|
||||||
|
// it: the earliest (by date, id) transport entry with a non-empty waypoints
|
||||||
|
// array whose date falls within the leg's stop date range, inclusive. A given
|
||||||
|
// transport bridges at most one leg — once claimed by a leg it's excluded
|
||||||
|
// from later legs, so the first leg in stop order wins.
|
||||||
|
export function buildLegWaypointsResolver(entries) {
|
||||||
|
const candidates = entries
|
||||||
|
.filter((e) => e.type === 'transport' && Array.isArray(e.waypoints) && e.waypoints.length > 0)
|
||||||
|
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : a.id - b.id));
|
||||||
|
const used = new Set();
|
||||||
|
|
||||||
|
return function waypointsForLeg(fromDate, toDate) {
|
||||||
|
for (const e of candidates) {
|
||||||
|
if (used.has(e.id)) continue;
|
||||||
|
if (e.date >= fromDate && e.date <= toDate) {
|
||||||
|
used.add(e.id);
|
||||||
|
return e.waypoints;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Validation + normalization for transport-entry scenic waypoints (see
|
||||||
|
// "Scenic waypoints" in docs/API.md).
|
||||||
|
|
||||||
|
const MAX_WAYPOINTS = 8;
|
||||||
|
const MAX_NAME_LEN = 120;
|
||||||
|
|
||||||
|
function validCoord(v, min, max) {
|
||||||
|
return typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a waypoints array (already known non-empty/array by the caller's
|
||||||
|
// clearing check). Returns { error } or { value: normalizedArray }.
|
||||||
|
export function validateWaypoints(waypoints) {
|
||||||
|
if (!Array.isArray(waypoints)) {
|
||||||
|
return { error: 'waypoints must be an array' };
|
||||||
|
}
|
||||||
|
if (waypoints.length > MAX_WAYPOINTS) {
|
||||||
|
return { error: 'at most 8 waypoints' };
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const w of waypoints) {
|
||||||
|
if (!w || typeof w !== 'object' || Array.isArray(w)) {
|
||||||
|
return { error: 'waypoint lat/lng out of range' };
|
||||||
|
}
|
||||||
|
if (!validCoord(w.lat, -90, 90) || !validCoord(w.lng, -180, 180)) {
|
||||||
|
return { error: 'waypoint lat/lng out of range' };
|
||||||
|
}
|
||||||
|
const point = { lat: w.lat, lng: w.lng };
|
||||||
|
if (w.name !== undefined && w.name !== null) {
|
||||||
|
if (typeof w.name !== 'string' || w.name.length > MAX_NAME_LEN) {
|
||||||
|
return { error: 'waypoint name must be a string of at most 120 characters' };
|
||||||
|
}
|
||||||
|
const trimmed = w.name.trim();
|
||||||
|
if (trimmed.length > MAX_NAME_LEN) {
|
||||||
|
return { error: 'waypoint name must be a string of at most 120 characters' };
|
||||||
|
}
|
||||||
|
if (trimmed !== '') point.name = trimmed;
|
||||||
|
}
|
||||||
|
out.push(point);
|
||||||
|
}
|
||||||
|
return { value: out };
|
||||||
|
}
|
||||||
+73
-1
@@ -325,7 +325,7 @@ test('entry CRUD and full-row shape', async () => {
|
|||||||
const entry = create.body.entry;
|
const entry = create.body.entry;
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
Object.keys(entry).sort(),
|
Object.keys(entry).sort(),
|
||||||
['auto_ref', 'date', 'end_date', 'details', 'end_time', 'id', 'lat', 'lng', 'location_name', 'paid_by', 'participants', 'price', 'rental', 'segments', 'sort_order', 'split_mode', 'start_time', 'title', 'transport_mode', 'trip_id', 'type'].sort()
|
['auto_ref', 'date', 'end_date', 'details', 'end_time', 'id', 'lat', 'lng', 'location_name', 'paid_by', 'participants', 'price', 'rental', 'segments', 'sort_order', 'split_mode', 'start_time', 'title', 'transport_mode', 'trip_id', 'type', 'waypoints'].sort()
|
||||||
);
|
);
|
||||||
assert.equal(entry.end_date, null);
|
assert.equal(entry.end_date, null);
|
||||||
assert.equal(entry.details, '');
|
assert.equal(entry.details, '');
|
||||||
@@ -607,6 +607,78 @@ test('directions requires auth', async () => {
|
|||||||
assert.equal(noAuth.status, 401);
|
assert.equal(noAuth.status, 401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('directions: via param routes through the extra points, in order (mocked fetch)', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const original = global.fetch;
|
||||||
|
let requestedUrl;
|
||||||
|
global.fetch = async (url) => {
|
||||||
|
requestedUrl = url;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
code: 'Ok',
|
||||||
|
routes: [{ distance: 1000, geometry: { coordinates: [[98.9853, 18.7883], [99.0, 18.8]] } }],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await agent.get(
|
||||||
|
'/api/directions?from=18.7883,98.9853&to=18.8,99.0&via=46.5,10.45|46.6,10.5'
|
||||||
|
);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
// OSRM coordinate order is {lng},{lat}, from -> via... -> to.
|
||||||
|
assert.ok(requestedUrl.includes('98.9853,18.7883;10.45,46.5;10.5,46.6;99,18.8'));
|
||||||
|
} finally {
|
||||||
|
global.fetch = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('directions: malformed via is rejected with 400', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
|
||||||
|
const malformed = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0&via=abc');
|
||||||
|
assert.equal(malformed.status, 400);
|
||||||
|
|
||||||
|
const outOfRange = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0&via=999,10');
|
||||||
|
assert.equal(outOfRange.status, 400);
|
||||||
|
|
||||||
|
const tooMany = await agent.get(
|
||||||
|
`/api/directions?from=18.7883,98.9853&to=18.8,99.0&via=${Array.from({ length: 9 }, () => '1,1').join('|')}`
|
||||||
|
);
|
||||||
|
assert.equal(tooMany.status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('directions: cache distinguishes requests with different via points', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const original = global.fetch;
|
||||||
|
let calls = 0;
|
||||||
|
global.fetch = async () => {
|
||||||
|
calls += 1;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
code: 'Ok',
|
||||||
|
routes: [{ distance: 1000, geometry: { coordinates: [[98.9853, 18.7883], [99.0, 18.8]] } }],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const noVia = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0');
|
||||||
|
assert.equal(noVia.status, 200);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
|
||||||
|
const withVia = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0&via=46.5,10.45');
|
||||||
|
assert.equal(withVia.status, 200);
|
||||||
|
assert.equal(calls, 2, 'a different via should not hit the no-via cache entry');
|
||||||
|
|
||||||
|
const sameVia = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0&via=46.5,10.45');
|
||||||
|
assert.equal(sameVia.status, 200);
|
||||||
|
assert.equal(calls, 2, 'identical via should be served from cache');
|
||||||
|
} finally {
|
||||||
|
global.fetch = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Unknown /api route -> JSON 404
|
// Unknown /api route -> JSON 404
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -124,6 +124,167 @@ test('route stops include transport_mode (entry value, or null for airport stops
|
|||||||
assert.ok(airportStops.every((s) => s.transport_mode === null));
|
assert.ok(airportStops.every((s) => s.transport_mode === null));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scenic waypoints
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('waypoints: accepted on transport entries (POST), returned parsed in entry JSON', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
const base = `/api/trips/${trip.id}/entries`;
|
||||||
|
|
||||||
|
const res = await agent.post(base).send({
|
||||||
|
date: '2026-08-01', type: 'transport', title: 'Alpine drive',
|
||||||
|
waypoints: [{ lat: 46.5, lng: 10.45, name: 'Stelvio Pass' }, { lat: 46.6, lng: 10.5 }],
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
assert.deepEqual(res.body.entry.waypoints, [
|
||||||
|
{ lat: 46.5, lng: 10.45, name: 'Stelvio Pass' },
|
||||||
|
{ lat: 46.6, lng: 10.5 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const absent = await agent.post(base).send({ date: '2026-08-01', type: 'transport', title: 'x' });
|
||||||
|
assert.equal(absent.status, 201);
|
||||||
|
assert.equal(absent.body.entry.waypoints, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('waypoints: PATCH accepts an array; [] and null both clear to null', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
const base = `/api/trips/${trip.id}/entries`;
|
||||||
|
|
||||||
|
const entry = (await agent.post(base).send({ date: '2026-08-01', type: 'transport', title: 'x' })).body.entry;
|
||||||
|
|
||||||
|
const patched = await agent.patch(`/api/entries/${entry.id}`).send({
|
||||||
|
waypoints: [{ lat: 1, lng: 2 }],
|
||||||
|
});
|
||||||
|
assert.equal(patched.status, 200);
|
||||||
|
assert.deepEqual(patched.body.entry.waypoints, [{ lat: 1, lng: 2 }]);
|
||||||
|
|
||||||
|
const clearedEmpty = await agent.patch(`/api/entries/${entry.id}`).send({ waypoints: [] });
|
||||||
|
assert.equal(clearedEmpty.status, 200);
|
||||||
|
assert.equal(clearedEmpty.body.entry.waypoints, null);
|
||||||
|
|
||||||
|
await agent.patch(`/api/entries/${entry.id}`).send({ waypoints: [{ lat: 1, lng: 2 }] });
|
||||||
|
const clearedNull = await agent.patch(`/api/entries/${entry.id}`).send({ waypoints: null });
|
||||||
|
assert.equal(clearedNull.status, 200);
|
||||||
|
assert.equal(clearedNull.body.entry.waypoints, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('waypoints: rejected on non-transport entries', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
const base = `/api/trips/${trip.id}/entries`;
|
||||||
|
|
||||||
|
const res = await agent.post(base).send({
|
||||||
|
date: '2026-08-01', type: 'activity', title: 'x', waypoints: [{ lat: 1, lng: 2 }],
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.deepEqual(res.body, { error: 'waypoints are only allowed on transport entries' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('waypoints: more than 8 rejected', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
const base = `/api/trips/${trip.id}/entries`;
|
||||||
|
|
||||||
|
const nine = Array.from({ length: 9 }, (_, i) => ({ lat: i, lng: i }));
|
||||||
|
const res = await agent.post(base).send({ date: '2026-08-01', type: 'transport', title: 'x', waypoints: nine });
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.deepEqual(res.body, { error: 'at most 8 waypoints' });
|
||||||
|
|
||||||
|
const eight = Array.from({ length: 8 }, (_, i) => ({ lat: i, lng: i }));
|
||||||
|
const ok = await agent.post(base).send({ date: '2026-08-01', type: 'transport', title: 'x', waypoints: eight });
|
||||||
|
assert.equal(ok.status, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('waypoints: bad coord rejected', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
const base = `/api/trips/${trip.id}/entries`;
|
||||||
|
|
||||||
|
const badLat = await agent.post(base).send({
|
||||||
|
date: '2026-08-01', type: 'transport', title: 'x', waypoints: [{ lat: 999, lng: 10 }],
|
||||||
|
});
|
||||||
|
assert.equal(badLat.status, 400);
|
||||||
|
assert.deepEqual(badLat.body, { error: 'waypoint lat/lng out of range' });
|
||||||
|
|
||||||
|
const badLng = await agent.post(base).send({
|
||||||
|
date: '2026-08-01', type: 'transport', title: 'x', waypoints: [{ lat: 10, lng: -999 }],
|
||||||
|
});
|
||||||
|
assert.equal(badLng.status, 400);
|
||||||
|
assert.deepEqual(badLng.body, { error: 'waypoint lat/lng out of range' });
|
||||||
|
|
||||||
|
const nonArray = await agent.post(base).send({
|
||||||
|
date: '2026-08-01', type: 'transport', title: 'x', waypoints: 'nope',
|
||||||
|
});
|
||||||
|
assert.equal(nonArray.status, 400);
|
||||||
|
assert.deepEqual(nonArray.body, { error: 'waypoints must be an array' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('waypoints: bad name rejected', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
const base = `/api/trips/${trip.id}/entries`;
|
||||||
|
|
||||||
|
const tooLong = await agent.post(base).send({
|
||||||
|
date: '2026-08-01', type: 'transport', title: 'x',
|
||||||
|
waypoints: [{ lat: 1, lng: 2, name: 'x'.repeat(121) }],
|
||||||
|
});
|
||||||
|
assert.equal(tooLong.status, 400);
|
||||||
|
assert.deepEqual(tooLong.body, { error: 'waypoint name must be a string of at most 120 characters' });
|
||||||
|
|
||||||
|
const notString = await agent.post(base).send({
|
||||||
|
date: '2026-08-01', type: 'transport', title: 'x',
|
||||||
|
waypoints: [{ lat: 1, lng: 2, name: 42 }],
|
||||||
|
});
|
||||||
|
assert.equal(notString.status, 400);
|
||||||
|
|
||||||
|
// Empty name after trim is dropped rather than rejected.
|
||||||
|
const emptyName = await agent.post(base).send({
|
||||||
|
date: '2026-08-01', type: 'transport', title: 'x',
|
||||||
|
waypoints: [{ lat: 1, lng: 2, name: ' ' }],
|
||||||
|
});
|
||||||
|
assert.equal(emptyName.status, 201);
|
||||||
|
assert.deepEqual(emptyName.body.entry.waypoints, [{ lat: 1, lng: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('route: a ground leg bridged by a waypoint-bearing transport exposes leg.waypoints; air legs and unbridged legs have []', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
const base = `/api/trips/${trip.id}/entries`;
|
||||||
|
|
||||||
|
await agent.post(base).send({
|
||||||
|
date: '2026-08-01', type: 'activity', title: 'Start', location_name: 'A', lat: 10, lng: 10,
|
||||||
|
});
|
||||||
|
await agent.post(base).send({
|
||||||
|
date: '2026-08-02', type: 'transport', title: 'Drive', transport_mode: 'drive',
|
||||||
|
waypoints: [{ lat: 46.5, lng: 10.45, name: 'Stelvio Pass' }],
|
||||||
|
});
|
||||||
|
await agent.post(base).send({
|
||||||
|
date: '2026-08-03', type: 'activity', title: 'End', location_name: 'B', lat: 20, lng: 20,
|
||||||
|
});
|
||||||
|
await agent.post(base).send({
|
||||||
|
date: '2026-08-04', type: 'flight', title: 'BKK-CNX',
|
||||||
|
segments: [
|
||||||
|
{ from: { code: 'BKK', lat: 13.68, lng: 100.75 }, to: { code: 'CNX', lat: 18.77, lng: 98.96 } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await agent.get(`/api/trips/${trip.id}/route`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const legs = res.body.legs;
|
||||||
|
assert.ok(legs.length >= 2);
|
||||||
|
|
||||||
|
const groundLeg = legs.find((l) => l.mode === 'ground');
|
||||||
|
assert.ok(groundLeg);
|
||||||
|
assert.deepEqual(groundLeg.waypoints, [{ lat: 46.5, lng: 10.45, name: 'Stelvio Pass' }]);
|
||||||
|
|
||||||
|
const airLeg = legs.find((l) => l.mode === 'air');
|
||||||
|
assert.ok(airLeg);
|
||||||
|
assert.deepEqual(airLeg.waypoints, []);
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Data migrations (legacy types -> new types)
|
// Data migrations (legacy types -> new types)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user