Make the airport→hotel drop-off a first-class, independently-editable leg

The drop-off (land → hotel, 'drop bags') was always by car, not L3-editable,
and the walk/taxi toggle was trip-global. It also read dayBase() rather than
the actual booked hotel, and the open route card went stale after enrichment
(the rail updated, the card didn't).

- transferIn is now a full leg: {mode, conf, geometry, hotelName} (both the
  apply_flight_anchors tool and the healTravelAnchors self-heal build it).
- enrichLegs routes the drop-off FIRST (so the stop-leg token bails can't
  starve it), idempotently, in its own mode; renderMap draws it from its
  routed geometry, walk=green / taxi=blue.
- The drop-off rail row is a normal clickable leg (not struct-leg): clicking
  it opens the same L3 editor as any stop-to-stop leg, with the walk/taxi
  toggle.
- openL3 generalises to openLegEditor(leg, from, to, ...) so any leg with
  explicit endpoints is editable; openArrivalEditor() edits the drop-off.
- The toggle is now per-leg (setLegMode) — it re-routes just the leg being
  edited and keeps the card, the itinerary rail, and the map in sync (the
  rail re-renders live, and enrichment refreshes an open card), killing the
  stale-state-after-toggle bug. rebuildLegs preserves each leg's mode.
- setTravelMode (no UI, kept as a global API) genuinely flips every on-ground
  leg + the drop-off.

Verified: cdp_leg 17/17 (drop-off routed on load, L3-editable, mode+colour+
pane update live, card==rail), struct 13/13, dates 7/7, Florence/Boston smoke
0 JS errors.
This commit is contained in:
Greg Pomerantz 2026-09-09 19:33:48 -04:00
parent 7bdb455074
commit 76749affb6

View File

@ -215,7 +215,7 @@ function healTravelAnchors() {
const ap = arr.stations && arr.stations.to;
first.isArrival = true;
first.arrivalFlight = { code: codeOf(arr.to) || codeOf(arr.name) || arr.to || 'airport', at: ap || null, time: arr.arrive || '', flight: String(arr.name || '').split(' · ')[0], from: arr.from || '', to: arr.to || '' };
first.transferIn = { at: ap || null, hotelAt: hotelFirst, dur: (ap && hotelFirst) ? (driveMin({ at: ap }, hotelFirst) || 60) : 60 };
first.transferIn = { at: ap || null, hotelAt: hotelFirst, dur: (ap && hotelFirst) ? (driveMin({ at: ap }, hotelFirst) || 60) : 60, mode: 'car', conf: 2, geometry: null, hotelName: hotelFirst ? hotelFirst.name : 'hotel' };
}
if (dep && !last.departureFlight) {
const ap = dep.stations && dep.stations.from;
@ -380,7 +380,7 @@ const TOOLS = {
// bookings (hard anchors), not stops — the day starts/ends with them.
first.isArrival = true;
first.arrivalFlight = { code: arrAp ? arrAp.code : (arr.airport || arr.city || 'airport'), at: arrAp ? arrAp.at : null, time: arr.time, flight: arr.flight || '', from: arr.from || '', to: arr.city || '' };
first.transferIn = { at: arrAp ? arrAp.at : null, hotelAt: hotelFirst, dur: arrTransfer };
first.transferIn = { at: arrAp ? arrAp.at : null, hotelAt: hotelFirst, dur: arrTransfer, mode: 'car', conf: 2, geometry: null, hotelName: hotelFirst ? hotelFirst.name : 'hotel' };
last.isDeparture = true;
last.departureFlight = { code: depAp ? depAp.code : (dep.airport || dep.city || 'airport'), at: depAp ? depAp.at : null, time: dep.time, flight: dep.flight || '', from: dep.city || '', to: dep.to || '' };
last.transferOut = { at: depAp ? depAp.at : null, hotelAt: hotelLast, dur: depTransfer };
@ -607,6 +607,20 @@ let legToken = 0;
async function enrichLegs(d = day) {
if (!routerOn) return;
const token = ++legToken;
// the arrival drop-off (airport→hotel) is a first-class leg — route it FIRST, before the
// stop-leg loop, so the stop-leg token bails below can't starve it. The value is day-scoped
// and idempotent (apply even if a newer enrich started); skip when already routed in this
// mode. Only re-render if it's still the day on screen.
if (d.isArrival && d.transferIn && d.transferIn.at && d.transferIn.hotelAt) {
const t = d.transferIn, want = t.mode || 'car';
if (!(t.conf === 3 && t.geometry && t._routedMode === want)) {
const res = await routePts([t.at, t.hotelAt], want);
if (res) { t.dur = res.durMin; t.conf = 3; t.geometry = res.geometry; t._routedMode = want; }
else { t.conf = 1; }
if (l3 && l3.leg === t) l3.refreshCard && l3.refreshCard();
if (d === day) renderAll();
}
}
for (let i = 0; i < d.stops.length - 1; i++) {
const a = d.stops[i], b = d.stops[i + 1];
if (a.kind === 'region' || b.kind === 'region' || !d.legs[i]) continue;
@ -743,13 +757,15 @@ function layout(d = day) {
return t + returnDur(d);
}
function rebuildLegs(d = day, enrich = true) {
const prevModes = (d.legs || []).map(g => (g && (g.mode === 'foot' || g.mode === 'car')) ? g.mode : null); // keep each leg's walk/taxi choice across rebuilds
const pm = i => prevModes[i] || travelMode;
d.legs = [];
delete d.retRouted;
for (let i = 0; i < d.stops.length - 1; i++) {
const a = d.stops[i], b = d.stops[i + 1];
// a vague (region) stop at either end → worst-case leg until refined
if (a.kind === 'region' || b.kind === 'region') {
d.legs.push({ mode: travelMode, dur: travelMode === 'car' ? 9 : 17, conf: 1, vague: true });
d.legs.push({ mode: pm(i), dur: pm(i) === 'car' ? 9 : 17, conf: 1, vague: true });
} else {
// legs touching a transit stop: fixed duration (flight + taxi/bus),
// optionally broken down via M.multimodal entries
@ -772,7 +788,7 @@ function rebuildLegs(d = day, enrich = true) {
} else {
// straight-line estimate at the current travel mode's speed, until the
// router upgrades it (or proves out of coverage)
d.legs.push({ mode: travelMode, dur: estLegMin(travelMode, a.at, b.at), conf: 1 });
d.legs.push({ mode: pm(i), dur: estLegMin(pm(i), a.at, b.at), conf: 1 });
}
}
}
@ -839,13 +855,12 @@ function renderMap() {
retLines.push(retLine);
});
}
if (obj.isArrival && obj.arrivalFlight && obj.arrivalFlight.at) { // airport → hotel drop-off (hoverable via its rail row, like any other route)
const h = dayBase(obj);
if (h) {
arrLine = L.polyline([obj.arrivalFlight.at, h.at], { color: '#274b8f', weight: 5, opacity: .85, dashArray: '6 4' }).addTo(map);
otherLayers.push(arrLine);
arrLines.push(arrLine);
}
if (obj.isArrival && obj.transferIn && obj.transferIn.at && obj.transferIn.hotelAt) { // airport → hotel drop-off: a first-class leg, drawn from its own routed geometry
const t = obj.transferIn;
const pts = t.geometry || [t.at, t.hotelAt];
arrLine = L.polyline(pts, { color: t.mode === 'foot' ? '#5a7d5a' : '#274b8f', weight: 5, opacity: .85, dashArray: '6 4' }).addTo(map);
otherLayers.push(arrLine);
arrLines.push(arrLine);
}
obj.stops.forEach((s, i) => {
const dim = s.state !== 'planned';
@ -1206,20 +1221,20 @@ function renderArrivalHeader(body) {
c.append(meta);
if (f.at) c.addEventListener('click', () => focusPoint(f.at));
body.append(c);
// the ride to the hotel to drop bags — always by car, before the first stop
// the ride to the hotel to drop bags — a first-class leg (walk or taxi), before the first stop.
// Click it to edit the route / toggle walk vs taxi, exactly like any stop-to-stop leg.
const t = day.transferIn || {};
const tr = el('div', 'leg-row struct-leg');
tr.append(el('span', 'leg-ico', '🚗'));
tr.append(document.createTextNode('to ' + (dayBase() ? dayBase().name : 'hotel') + ' — drop bags'));
const tr = el('div', 'leg-row');
tr.append(el('span', 'leg-ico', t.mode === 'foot' ? '🚶' : '🚗'));
tr.append(document.createTextNode('to ' + (t.hotelName || (dayBase() ? dayBase().name : 'hotel')) + ' — drop bags'));
tr.append(document.createTextNode(' '));
tr.append(tchip(t.dur || 30, 2));
// a real route like any other: hovering highlights it on the map, clicking
// flies there. (Always by car — you taxi in from the airport.)
const on = { weight: 7, opacity: 1, color: '#274b8f', dashArray: null };
const off = { color: '#274b8f', weight: 5, opacity: .85, dashArray: '6 4' };
tr.append(tchip(t.dur || 30, t.conf || 2));
const ac = t.mode === 'foot' ? '#5a7d5a' : '#274b8f';
const on = { weight: 7, opacity: 1, color: ac, dashArray: null };
const off = { color: ac, weight: 5, opacity: .85, dashArray: '6 4' };
tr.addEventListener('mouseenter', () => arrLines.forEach(l => l.setStyle(on)));
tr.addEventListener('mouseleave', () => arrLines.forEach(l => l.setStyle(off)));
tr.addEventListener('click', () => { const h = dayBase(); if (f.at && h) map.fitBounds(L.latLngBounds([f.at, h.at]).pad(0.5)); });
tr.addEventListener('click', () => openArrivalEditor());
body.append(tr);
}
@ -1644,41 +1659,50 @@ function haversine(a, b) {
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toR(a[0])) * Math.cos(toR(b[0])) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
function openL3(legIdx) {
// Generic leg editor. Works on ANY leg described by an explicit from/to pair, so the
// airport→hotel drop-off is editable with the same machinery as a stop-to-stop leg:
// draggable waypoints, a walk/taxi toggle that re-routes THIS leg, and a live
// itinerary pane (the rail re-renders as you edit — no more stale times behind the
// card). The map is left to the editor's own preview line; renderAll syncs it on close.
function openLegEditor(leg, from, to, title, kind, idx) {
closeL3();
const a = day.stops[legIdx].at, b = day.stops[legIdx + 1].at;
const leg = day.legs[legIdx];
const a = from, b = to;
const mid = [(a[0] + b[0]) / 2 + 0.0012, (a[1] + b[1]) / 2 - 0.0018];
const layer = L.layerGroup().addTo(map);
const line = L.polyline([a, mid, b], { color: '#b5533c', weight: 5, opacity: .9, dashArray: '6 6' }).addTo(layer);
const line = L.polyline(leg.geometry || [a, mid, b], { color: '#b5533c', weight: 5, opacity: .9, dashArray: '6 6' }).addTo(layer);
const mk = p => L.marker(p, { draggable: true, icon: L.divIcon({ className: 'wp', html: '<div style="width:16px;height:16px;border-radius:50%;background:#b5533c;border:3px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.5)"></div>', iconSize: [16, 16], iconAnchor: [8, 8] }) }).addTo(layer);
const m1 = mk(a), m2 = mk(mid), m3 = mk(b);
const box = $('#l3-editor .l3-time');
let rt = 0; // debounce token for the router round-trip
let rt = 0, railT = 0; // router debounce token + rail re-render debounce
const refreshCard = () => { box.innerHTML = 'leg time: '; box.append(tchip(leg.dur, leg.conf || 3)); renderL3Mode(leg); };
const railSoon = () => { clearTimeout(railT); railT = setTimeout(() => { if (l3 && l3.leg === leg) renderRail(); }, 180); };
const upd = () => {
const p1 = m1.getLatLng(), p2 = m2.getLatLng(), p3 = m3.getLatLng();
line.setLatLngs([[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]]);
const dist = haversine([p1.lat, p1.lng], [p2.lat, p2.lng]) + haversine([p2.lat, p2.lng], [p3.lat, p3.lng]);
leg._pts = [[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]];
line.setLatLngs(leg._pts);
const dist = haversine(leg._pts[0], leg._pts[1]) + haversine(leg._pts[1], leg._pts[2]);
leg.dur = Math.max(4, Math.round(dist / 1000 / (legSpeedKmh(leg.mode) / 60))); // instant straight-line estimate at the leg's mode
leg.conf = 1;
box.innerHTML = 'leg time: est. '; box.append(tchip(leg.dur, 1));
renderBudget();
renderBudget(); railSoon();
const t = ++rt;
routePts([[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]], leg.mode).then(res => {
if (t !== rt || !l3) return;
routePts(leg._pts, leg.mode).then(res => {
if (t !== rt || !l3 || l3.leg !== leg) return;
if (res) { leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; line.setLatLngs(res.geometry); }
box.innerHTML = 'leg time: ' + (res ? '' : 'est. ');
box.append(tchip(leg.dur, leg.conf));
renderBudget();
renderBudget(); renderRail(); // keep the itinerary pane in sync live
});
};
[m1, m2, m3].forEach(m => m.on('drag', upd));
$('#l3-title').textContent = `${day.stops[legIdx].name}${day.stops[legIdx + 1].name}`;
box.innerHTML = 'leg time: '; box.append(tchip(leg.dur, 3));
$('#l3-title').textContent = title;
refreshCard();
renderL3Mode(leg);
$('#l3-foot').onclick = () => setLegMode(leg, 'foot');
$('#l3-car').onclick = () => setLegMode(leg, 'car');
$('#l3-editor').classList.remove('hidden');
map.fitBounds(L.latLngBounds([a, b]).pad(0.4));
l3 = { layer, idx: legIdx };
l3 = { layer, leg, from: a, to: b, kind, idx, refreshCard };
const obs = setInterval(() => {
if (!$('#l3-editor').classList.contains('hidden')) return;
clearInterval(obs);
@ -1686,25 +1710,55 @@ function openL3(legIdx) {
renderAll();
}, 250);
}
// Stop-to-stop leg: day.legs[i] between stops i and i+1.
function openL3(legIdx) {
const leg = day.legs[legIdx]; if (!leg) return;
openLegEditor(leg, day.stops[legIdx].at, day.stops[legIdx + 1].at,
`${day.stops[legIdx].name}${day.stops[legIdx + 1].name}`, 'stop', legIdx);
}
// The airport→hotel drop-off: a first-class leg with its own mode + geometry.
function openArrivalEditor() {
const t = day.transferIn; if (!t || !t.at || !t.hotelAt) return;
openLegEditor(t, t.at, t.hotelAt, 'Airport → ' + (t.hotelName || 'hotel'), 'arrival');
}
function closeL3() {
if (l3) { map.removeLayer(l3.layer); l3 = null; }
$('#l3-editor').classList.add('hidden');
}
$('#l3-close').onclick = closeL3;
// the walk/taxi toggle lives on the route card itself — the popup a leg click
// opens. It's shown only for on-ground legs (a flight or train+bus segment has
// a fixed mode); flipping it re-estimates every on-ground leg in the trip.
// the walk/taxi toggle lives on the route card and changes the MODE OF THE LEG
// BEING EDITED (per-leg) — so the airport→hotel drop-off can be walked or taxi'd
// independently of the rest of the day. Flipping it re-routes just that leg and keeps
// the card, the itinerary pane, and the map in sync.
function renderL3Mode(leg) {
const box = $('#l3-mode'); if (!box) return;
const ground = !!leg && (leg.mode === 'foot' || leg.mode === 'car');
box.classList.toggle('hidden', !ground);
if (!ground) return;
$('#l3-foot').classList.toggle('on', travelMode === 'foot');
$('#l3-car').classList.toggle('on', travelMode === 'car');
$('#l3-foot').classList.toggle('on', leg.mode === 'foot');
$('#l3-car').classList.toggle('on', leg.mode === 'car');
}
// Change one leg's mode: re-estimate it straight-line for instant feedback, then
// re-route it for the real road time. Only this leg is touched.
function setLegMode(leg, m) {
if (!leg || !l3 || l3.leg !== leg || m === leg.mode) return;
leg.mode = m;
leg.dur = leg._pts
? Math.max(4, Math.round((haversine(leg._pts[0], leg._pts[1]) + haversine(leg._pts[1], leg._pts[2])) / 1000 / (legSpeedKmh(m) / 60)))
: estLegMin(m, l3.from, l3.to);
leg.conf = 1;
renderRail(); renderL3Mode(leg);
if (l3.kind === 'arrival') renderMap(); // keep the drop-off line colour (walk=green, taxi=blue) in sync
const t = (leg._rt = (leg._rt || 0) + 1);
routePts(leg._pts || [l3.from, l3.to], m).then(res => {
if (leg._rt !== t || !l3 || l3.leg !== leg) return;
if (res) { leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; leg._routedMode = m; }
renderRail();
if (l3.kind === 'arrival') renderMap();
l3.refreshCard();
});
}
$('#l3-foot').onclick = () => setTravelMode('foot');
$('#l3-car').onclick = () => setTravelMode('car');
// ---------------- version history (every edit = a version) ----------------
// Both editors (user + chat) funnel through commit(): the snapshot captures
@ -2299,20 +2353,26 @@ function startApp() {
// the toggle UI lives on the route card (openL3); this just refreshes its
// state whenever the mode changes or the rail re-renders
function renderModeToggle() {
renderL3Mode(l3 && day && day.legs[l3.idx] ? day.legs[l3.idx] : null);
renderL3Mode(l3 ? l3.leg : null);
}
function setTravelMode(m) {
if (m === travelMode) return;
travelMode = m;
try { localStorage.setItem('trips.travemode', m); } catch {}
// re-estimate every day's on-ground legs at the new speed. Enrichment is not
// queued here — one fetch per day would cancel the token out — only the day
// you're looking at is re-routed in the background
M.days.forEach(md => { const d = days[md.id]; if (d) rebuildLegs(d, false); });
// global mode: force every on-ground leg (and the airport drop-off) to the new mode,
// then rebuild — which now preserves each leg's mode, so the override sticks. Enrichment
// is not queued per day (one fetch per day would cancel the token out); only the day you're
// looking at is re-routed in the background.
M.days.forEach(md => {
const d = days[md.id]; if (!d) return;
(d.legs || []).forEach(g => { if (g && (g.mode === 'foot' || g.mode === 'car' || g.vague)) g.mode = m; });
if (d.transferIn && d.transferIn.at) { d.transferIn.mode = m; d.transferIn._routedMode = null; }
rebuildLegs(d, false);
});
renderAll();
if (day) enrichLegs(day);
renderModeToggle();
if (l3) openL3(l3.idx); // rebind the open route card to the re-estimated leg
if (l3) (l3.kind === 'arrival' ? openArrivalEditor() : openL3(l3.idx)); // rebind the open route card to the re-estimated leg
}
function openDetail(s, actions) {