diff --git a/mock/app.js b/mock/app.js
index 05c9021..a2c07e0 100644
--- a/mock/app.js
+++ b/mock/app.js
@@ -214,7 +214,10 @@ function llmContext() {
layout(dd);
const stops = dd.stops.filter(s => s.state === 'planned').map(
s => `${fmt(s.start)} ${s.name} (${s.dur} min${s.price ? ', ' + s.price.amount + s.price.cur : ''})`);
- L.push(`${dd.label} [${dd.id} · ${dd.date}]: ${stops.length ? stops.join(' · ') : '(empty)'} — day ends ~${fmt(dd.startMin + (dd.wakingHours || 11) * 60)}`);
+ const arrInfo = (dd.isArrival && dd.transferIn && dd.arrLandingMin != null)
+ ? ` — lands ${fmt(dd.arrLandingMin)}, ${dd.transferIn.mode || 'car'} to hotel ${dd.transferIn.dur} min, first stop at ${fmt((dd.stops[0] || { start: dd.startMin }).start)}`
+ : '';
+ L.push(`${dd.label} [${dd.id} · ${dd.date}]: ${stops.length ? stops.join(' · ') : '(empty)'}${arrInfo} — day ends ~${fmt(layout(dd))}`);
}
return L.join('\n');
}
@@ -320,9 +323,10 @@ function driveMin(ap, hotelAt) { // rough airport<->hotel taxi time
// before the traveller had even reached the hotel).
function syncArrivalStart(d) {
if (!d || !d.isArrival || d.arrLandingMin == null || !d.transferIn) return;
- d.startMin = d.arrLandingMin + d.transferIn.dur;
- const arrEnd = Math.min(ARR_DAY_END, d.startMin + (d.wakingHours || 12) * 60);
- d.wakingHours = Math.max(2, Math.round((arrEnd - d.startMin) / 60));
+ d.startMin = d.arrLandingMin + d.transferIn.dur; // time at the HOTEL (bag drop)
+ const eff = d.startMin + firstLegDur(d); // + hotel → first stop = time at the first stop
+ const arrEnd = Math.min(ARR_DAY_END, eff + (d.wakingHours || 12) * 60);
+ d.wakingHours = Math.max(2, Math.round((arrEnd - eff) / 60));
}
function relabelDay(d, idx, dateStr) {
const [y, mo, da] = String(dateStr).split('-').map(Number);
@@ -466,13 +470,21 @@ function healTransfers() {
// geometry ends anywhere else than the current hotel point, drop it so it re-routes
ids.forEach(id => {
const dd = days[id];
- const leg = dd.retLeg;
- if (!leg || !leg.geometry || !leg.geometry.length) return;
const h = dd.base && dd.base.at;
- if (h && haversine(leg.geometry[leg.geometry.length - 1], h) > 300) {
- const lastSt = dd.stops[dd.stops.length - 1];
- leg.dur = (lastSt && h) ? estLegMin(leg.mode || travelMode, lastSt.at, h) : leg.dur;
- leg.conf = 1; leg.geometry = null; delete leg._routedMode; changed = true;
+ const leg = dd.retLeg;
+ if (leg && leg.geometry && leg.geometry.length) {
+ if (h && haversine(leg.geometry[leg.geometry.length - 1], h) > 300) {
+ const lastSt = dd.stops[dd.stops.length - 1];
+ leg.dur = (lastSt && h) ? estLegMin(leg.mode || travelMode, lastSt.at, h) : leg.dur;
+ leg.conf = 1; leg.geometry = null; delete leg._routedMode; changed = true;
+ }
+ }
+ const fl = dd.firstLeg; // the hotel → first-stop leg gets the same endpoint-drift check
+ if (fl && fl.geometry && fl.geometry.length) {
+ const f0 = dd.stops[0];
+ if (f0 && h && (haversine(fl.geometry[0], h) > 300 || haversine(fl.geometry[fl.geometry.length - 1], f0.at) > 300)) {
+ fl.dur = estLegMin(fl.mode || travelMode, h, f0.at); fl.conf = 1; fl.geometry = null; delete fl._routedMode; changed = true;
+ }
}
});
const first = days[ids[0]], last = days[ids[ids.length - 1]];
@@ -708,7 +720,12 @@ const TOOLS = {
const d = days[a.dayId]; if (!d) return { error: `unknown day "${a.dayId}"` };
const m = /^(\d{1,2}):(\d{2})$/.exec(String(a.time || '').trim()); if (!m) return { error: 'time must be HH:MM' };
const h = +m[1], mn = +m[2]; if (h > 23 || mn > 59) return { error: 'time must be HH:MM' };
- d.startMin = h * 60 + mn;
+ if (d.isArrival && d.arrLandingMin != null) {
+ // the arrival day's start is DERIVED (landing → transfer → hotel → first stop);
+ // moving "start" moves the landing time so the whole chain stays consistent
+ d.arrLandingMin = h * 60 + mn;
+ syncArrivalStart(d);
+ } else d.startMin = h * 60 + mn;
rebuildLegs(d); layout(d); commit(`[llm] ${d.label} starts ${fmt(d.startMin)}`); renderAll();
return { ok: true, action: 'set_start', day: d.label, start: fmt(d.startMin), day_ends: fmt(layout(d)) };
},
@@ -1018,6 +1035,25 @@ async function enrichLegs(d = day) {
if (d === day) renderAll();
}
}
+ // hotel → first stop: routed like any other leg (same router, same mode, same sanity
+ // guards) — the arrival day and every ordinary day get a real, editable time for it
+ if (d.stops.length && staysForDay(d).length === 1 && !stopAtHotel(d)) {
+ const f0 = d.stops[0];
+ if (f0.kind !== 'region' && f0.kind !== 'transit') {
+ const leg = getFirstLeg(d);
+ if (leg) {
+ const want = leg.mode;
+ if (!(leg.conf === 3 && leg.geometry && leg._routedMode === want)) {
+ const h = dayBase(d).at;
+ const res = saneRoute(await routePts([h, f0.at], want), [h, f0.at], want);
+ if (legTokens[key] !== token) return;
+ if (res) { leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; leg._routedMode = want; }
+ else { leg.conf = 1; leg.src = 'estimate — outside router coverage'; }
+ 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;
@@ -1063,7 +1099,7 @@ let days = {};
let curDay, day;
const stopById = id => day.stops.find(s => s.id === id);
const allStops = id => M.days.flatMap(d => d.stops).find(s => s.id === id);
-let markers = {}, legEls = {}, otherLayers = [], tempMarkers = [], retLine = null, retLines = [], arrLine = null, arrLines = [];
+let markers = {}, legEls = {}, otherLayers = [], tempMarkers = [], retLine = null, retLines = [], arrLine = null, arrLines = [], firstLine = null, firstLines = [];
let map, l3 = null, scriptBusy = false;
let dragCand = null; // candidate object currently being dragged (drawer / dock)
let compareItems = []; // items in the comparison dock (max 4)
@@ -1168,6 +1204,39 @@ function getReturnLeg(d = day) {
}
return d.retLeg;
}
+// Hotel → first stop: the OUT-leg of a day, kept as a first-class leg object exactly
+// like getReturnLeg (the in-leg), so it has its own mode, duration and route, renders
+// as the same leg-row, and is click-to-edit in the same L3 card. null = there is no
+// leg to render (no single base for the day, region/transit first stop).
+// The hotel → first-stop leg only makes sense when the day actually STARTS at the
+// base hotel. A travel day (city change) starts at the PREVIOUS city’s hotel — e.g.
+// “Breakfast & check-out” in Cartagena before the flight to Santa Marta, whose stay
+// is the Santa Marta hotel — so a hotel→first-stop leg from the new hotel would be a
+// 500 km nonsense line. First stops beyond the city radius ⇒ the day starts elsewhere.
+const LEG_CITY_KM = 25;
+function firstLegApplies(d = day) {
+ if (d.isArrival && !d.transferIn) return false; // nothing to leave from before the arrival drop-off
+ if (!d.stops.length || staysForDay(d).length !== 1) return false;
+ const first = d.stops[0];
+ if (first.kind === 'region' || first.kind === 'transit') return false;
+ const b = dayBase(d);
+ return !!(b && b.at && first.at && haversine(b.at, first.at) <= LEG_CITY_KM * 1000);
+}
+function getFirstLeg(d = day) {
+ if (!firstLegApplies(d)) { delete d.firstLeg; return null; } // self-heal a stale leg from an earlier layout
+ const first = d.stops[0], b = dayBase(d);
+ if (!d.firstLeg) {
+ const mode = d.firstMode || travelMode;
+ d.firstLeg = { mode, dur: estLegMin(mode, b.at, first.at), conf: 1 };
+ delete d.firstMode;
+ }
+ return d.firstLeg;
+}
+const stopAtHotel = (d = day) => firstLegApplies(d) && haversine(dayBase(d).at, d.stops[0].at) < 120;
+function firstLegDur(d = day) {
+ const leg = getFirstLeg(d);
+ return (leg && !stopAtHotel(d)) ? leg.dur : 0;
+}
function returnDur(d = day) {
if (d.isDeparture) return 0; // departure day: the train is the end of the day
const last = d.stops[d.stops.length - 1];
@@ -1180,7 +1249,11 @@ function returnDur(d = day) {
return Math.max(1, Math.ceil(Math.max(...list.map(o => haversine(last.at, o.at))) / 1000 / (legSpeedKmh(travelMode) / 60)));
}
function layout(d = day) {
- let t = d.startMin;
+ // Arrival day: startMin is the time you reach the HOTEL (landing + transfer); the
+ // hotel → first-stop leg is added on top. Other days: startMin is the time you are
+ // AT the first stop — the hotel leg happens before it (shown as the "depart HH:MM"
+ // hotel node), so it never shifts the stop times.
+ let t = d.startMin + (d.isArrival ? firstLegDur(d) : 0);
d.stops.forEach((s, i) => { s.start = t; t += s.dur; if (d.legs[i]) t += d.legs[i].dur; });
return t + returnDur(d);
}
@@ -1192,6 +1265,9 @@ function rebuildLegs(d = day, enrich = true) {
delete d.retRouted;
delete d.retLeg;
if (prevRet) d.retMode = prevRet;
+ const prevFirst = d.firstLeg && (d.firstLeg.mode === 'foot' || d.firstLeg.mode === 'car') ? d.firstLeg.mode : null; // and the out-leg's choice
+ delete d.firstLeg;
+ if (prevFirst) d.firstMode = prevFirst;
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
@@ -1252,7 +1328,7 @@ function renderMap() {
Object.values(markers).forEach(m => map.removeLayer(m)); markers = {};
Object.values(legEls).forEach(l => map.removeLayer(l)); legEls = {};
otherLayers.forEach(l => map.removeLayer(l)); otherLayers = [];
- retLine = null; retLines = []; arrLine = null; arrLines = [];
+ retLine = null; retLines = []; arrLine = null; arrLines = []; firstLine = null; firstLines = [];
// every day, faintly — the whole trip is always visible
M.days.forEach(d => {
const obj = days[d.id];
@@ -1286,6 +1362,16 @@ function renderMap() {
retLines.push(retLine);
});
}
+ const flg = getFirstLeg(obj); // hotel → first stop: drawn like the other legs, coloured by mode
+ if (pts.length && !obj.isDeparture && flg && !stopAtHotel(obj)) {
+ const hb = dayBase(obj);
+ if (hb && hb.at) {
+ const g = flg.geometry || [hb.at, pts[0]];
+ firstLine = L.polyline(g, { color: flg.mode === 'foot' ? '#5a7d5a' : '#274b8f', weight: 4, opacity: .85, dashArray: '5 5' }).addTo(map);
+ otherLayers.push(firstLine);
+ firstLines.push(firstLine);
+ }
+ }
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];
@@ -1437,7 +1523,22 @@ function renderStrip() {
track.append(b);
};
let cur = t0;
- if (cur < day.startMin) block(cur, day.startMin - cur, 'gap', '', 'morning free');
+ const pre = firstLegDur(); // hotel → first stop
+ if (day.isArrival) {
+ if (cur < day.startMin) block(cur, day.startMin - cur, 'gap', '', 'landing + drop-off');
+ cur = day.startMin;
+ if (pre) { block(cur, pre, 'leg', '', `to first stop · ${pre} min`); cur += pre; }
+ } else {
+ const leaveAt = day.startMin - pre; // the hotel leg is BEFORE startMin
+ if (leaveAt <= t0) {
+ if (cur < day.startMin) block(cur, day.startMin - cur, 'gap', '', pre ? `morning free + ${pre}′ from hotel` : 'morning free');
+ cur = day.startMin;
+ } else {
+ if (cur < leaveAt) block(cur, leaveAt - cur, 'gap', '', 'morning free');
+ if (pre) block(leaveAt, pre, 'leg', '', `leave hotel · ${pre} min`);
+ cur = day.startMin;
+ }
+ }
day.stops.forEach((s, i) => {
if (cur < s.start) block(cur, s.start - cur, 'gap', '', 'free time');
const kindCls = s.kind === 'meal' ? 'meal' : s.kind === 'transit' ? 'transit' : s.kind === 'region' ? 'region' : 'visit';
@@ -1703,8 +1804,31 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') { if (disc)
function renderRail(flashIds = []) {
layout(); // assign start times before the cards render them
const body = $('#rail-body'); body.innerHTML = '';
- // a travel day OPENS with the arrival: airport card, then the ride in to drop bags
- if (day.isArrival && day.arrivalFlight) renderArrivalHeader(body);
+ // every day lives between two hotel nodes: you LEAVE the hotel (or land and drop
+ // bags on the travel day), move to the first stop, and come back at the end. Each
+ // movement is a leg row — identical in style and behaviour to the stop-to-stop ones.
+ if (day.isArrival && day.arrivalFlight) {
+ renderArrivalFlightCard(body); // ✈️ landed
+ const t = day.transferIn;
+ if (t && t.at && t.hotelAt)
+ renderGroundLegRow(body, {
+ leg: t, ico: MODE_ICO[t.mode] || '🚗',
+ label: `airport → ${t.hotelName || (dayBase() ? dayBase().name : 'hotel')}`,
+ hover: arrLines, color: t.mode === 'foot' ? '#5a7d5a' : '#274b8f',
+ title: 'Click to edit — drag the drop-off, walk or taxi', onClick: openArrivalEditor,
+ });
+ renderHotelNode(body, 'arrive'); // 🏨 arrive HH:MM · drop bags
+ } else if (firstLegApplies()) { // travel days (city change) start elsewhere — no node
+ renderHotelNode(body, 'depart'); // 🏨 depart HH:MM
+ }
+ const fl = getFirstLeg();
+ if (fl && day.stops.length && !stopAtHotel())
+ renderGroundLegRow(body, {
+ leg: fl, ico: MODE_ICO[fl.mode] || '🚶',
+ label: `${dayBase() ? dayBase().name : 'hotel'} → ${day.stops[0].name}`,
+ hover: firstLines, color: fl.mode === 'foot' ? '#5a7d5a' : '#274b8f',
+ title: 'Click to edit — drag the route, walk or taxi', onClick: openFirstEditor,
+ });
day.stops.forEach((s, i) => renderStopCard(body, s, i, flashIds));
// every day but the departure closes back at the hotel — the ride back, then
// the hotel card for the night, in the timeline where it belongs
@@ -1715,8 +1839,9 @@ function renderRail(flashIds = []) {
renderStrip(); renderBudget(); renderNudges(); renderSugg();
}
-// arrival-day header: the flight you landed on, then the car to the hotel
-function renderArrivalHeader(body) {
+// arrival-day flight card. The airport → hotel drop-off is NOT embedded here: it is a
+// stand-alone first-class leg row (renderGroundLegRow), exactly like every other leg.
+function renderArrivalFlightCard(body) {
const f = day.arrivalFlight; if (!f) return;
const c = el('div', 'stop-card struct-card arr-card');
const top = el('div', 'sc-top');
@@ -1731,42 +1856,83 @@ 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 — 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');
- 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, 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', () => openArrivalEditor());
- body.append(tr);
+}
+// every ground leg in the timeline renders through this one function — the arrival
+// drop-off, hotel → first stop, and the return ride. Same row, same chip, same click
+// → the same route card (drag the route, walk/taxi toggle).
+function renderGroundLegRow(body, o) {
+ const leg = o.leg;
+ const lr = el('div', 'leg-row' + (o.dim ? ' dim' : ''));
+ lr.append(el('span', 'leg-ico', o.ico));
+ lr.append(document.createTextNode(o.label + ' '));
+ lr.append(tchip(leg.dur || 0, leg.conf || 2));
+ if (o.title) lr.title = o.title;
+ const on = { weight: 7, opacity: 1, color: o.color, dashArray: null };
+ const off = { color: o.color, weight: 5, opacity: .85, dashArray: '5 5' };
+ if (o.hover) {
+ lr.addEventListener('mouseenter', () => o.hover.forEach(l => l.setStyle(on)));
+ lr.addEventListener('mouseleave', () => o.hover.forEach(l => l.setStyle(off)));
+ }
+ lr.addEventListener('click', o.onClick);
+ body.append(lr);
+ return lr;
+}
+// the hotel as a timeline NODE — "arrive HH:MM · drop bags" on the arrival day, "depart
+// HH:MM" on every other day (startMin minus the hotel → first-stop leg). Same card
+// family as the closing "tonight" card.
+function renderHotelNode(body, phase) {
+ const b = dayBase(); if (!b) return;
+ const c = el('div', 'stop-card struct-card hotel-card');
+ const top = el('div', 'sc-top');
+ top.append(el('span', 'leg-ico', '🏨'));
+ top.append(el('span', 'sc-name', b.name));
+ top.append(el('span', 'sc-times',
+ phase === 'arrive' ? `arrive ${fmt(day.startMin)} · drop bags`
+ : `depart ${fmt(Math.max(0, day.startMin - firstLegDur()))}`));
+ c.append(top);
+ const meta = el('div', 'sc-meta');
+ meta.append(el('span', 'tc t-sched', b.state === 'booked' ? 'booked' : 'base'));
+ meta.append(el('span', 'leg-sub', stayRange(b)));
+ c.append(meta);
+ const hm = hotelMks.filter((_, i) => staysForDay().some(s => s === stays[i]));
+ c.addEventListener('mouseenter', () => hm.forEach(m => flashMarker(m, true)));
+ c.addEventListener('mouseleave', () => hm.forEach(m => flashMarker(m, false)));
+ c.addEventListener('click', () => openDiscover('hotel'));
+ body.append(c);
}
-// the ride back to the hotel at the end of a (non-departure) day
+// the ride back to the hotel at the end of a (non-departure) day — same row style and
+// click-to-edit as every other leg
function renderReturnRow(body) {
- const multi = staysForDay().length > 1;
const leg = getReturnLeg(day);
- const ret = el('div', 'leg-row', `${MODE_ICO[(leg && leg.mode) || travelMode] || '•'}back to ${dayBase() ? dayBase().name : 'hotel'} `);
- ret.append(tchip(returnDur(), multi ? 1 : (leg ? leg.conf : 3)));
- if (multi) ret.append(el('span', 'leg-sub', `worst-case across ${staysForDay().length} hotels`));
- else if (leg) ret.title = 'Click to edit the ride — drag the drop-off, walk or taxi';
- const on = { weight: 7, opacity: 1, color: '#4a5160', dashArray: null };
- const off = { color: '#7a8291', weight: 4, opacity: .8, dashArray: '1 7' };
- ret.addEventListener('mouseenter', () => retLines.forEach(l => l.setStyle(on)));
- ret.addEventListener('mouseleave', () => retLines.forEach(l => l.setStyle(off)));
- // exactly like any stop-to-stop leg: click → route card with drag + walk/taxi toggle
- if (leg) ret.addEventListener('click', () => {
+ if (leg) {
const last = day.stops[day.stops.length - 1];
- openLegEditor(leg, last.at, dayBase().at, last.name + ' → ' + (dayBase() ? dayBase().name : 'hotel'), 'return');
- });
+ renderGroundLegRow(body, {
+ leg, ico: MODE_ICO[leg.mode] || '🚶',
+ label: `${last.name} → ${dayBase() ? dayBase().name : 'hotel'}`,
+ hover: retLines, color: '#7a8291',
+ title: 'Click to edit the ride — drag the drop-off, walk or taxi',
+ onClick: openReturnEditor,
+ });
+ return;
+ }
+ // no single anchor (multi-stay day or region/transit last stop): show the estimate
+ const ret = el('div', 'leg-row', `${MODE_ICO[travelMode] || '•'}back to ${dayBase() ? dayBase().name : 'hotel'} `);
+ ret.append(tchip(returnDur(), 1));
+ if (staysForDay().length > 1) ret.append(el('span', 'leg-sub', `worst-case across ${staysForDay().length} hotels`));
body.append(ret);
}
+function openReturnEditor() {
+ const leg = getReturnLeg(day); if (!leg) return;
+ const b = dayBase(); if (!b) return;
+ const last = day.stops[day.stops.length - 1];
+ openLegEditor(leg, last.at, b.at, `${last.name} → ${b.name}`, 'return');
+}
+function openFirstEditor() {
+ const leg = getFirstLeg(day); if (!leg) return;
+ const b = dayBase(); if (!b) return;
+ openLegEditor(leg, b.at, day.stops[0].at, `${b.name} → ${day.stops[0].name}`, 'first');
+}
// the hotel for tonight — in the timeline, after the ride back
function renderHotelCard(body) {
@@ -2019,7 +2185,7 @@ function renderBudget() {
const prim = day.stops.filter(s => s.state === 'planned');
const flex = day.stops.length - prim.length;
const planned = prim.reduce((a, s) => a + s.dur, 0) + day.legs.reduce((a, g) => a + g.dur, 0)
- + (prim.length ? returnDur() : 0);
+ + (prim.length ? firstLegDur() + returnDur() : 0);
const flexMin = day.stops.filter(s => s.state !== 'planned').reduce((a, s) => a + s.dur, 0);
const end = layout();
const cap = day.wakingHours * 60;
@@ -2273,14 +2439,14 @@ function setLegMode(leg, m) {
leg.conf = 1;
if (l3.kind === 'arrival') syncArrivalStart(day); // reflect the new (estimated) transfer right away
renderRail(); renderL3Mode(leg);
- if (l3.kind === 'arrival') renderMap(); // keep the drop-off line colour (walk=green, taxi=blue) in sync
+ if (l3.kind === 'arrival' || l3.kind === 'first') renderMap(); // keep the leg 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(r => { const res = saneRoute(r, leg._pts || [l3.from, l3.to], m);
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; }
if (l3.kind === 'arrival') syncArrivalStart(day); // and again once the real route lands
renderRail();
- if (l3.kind === 'arrival') renderMap();
+ if (l3.kind === 'arrival' || l3.kind === 'first') renderMap();
l3.refreshCard();
});
}
@@ -2504,7 +2670,7 @@ $('#scope-trip').onclick = () => setScope('trip');
function dayStats(d) {
const planned = d.stops.filter(s => s.state === 'planned');
const dur = planned.reduce((a, s) => a + s.dur, 0) + d.legs.reduce((a, g) => a + g.dur, 0)
- + (planned.length && !d.isDeparture ? returnDur(d) : 0);
+ + (planned.length ? firstLegDur(d) + (d.isDeparture ? 0 : returnDur(d)) : 0);
return { count: planned.length, dur, end: layout(d),
price: planned.reduce((a, s) => a + (s.price?.amount || 0), 0),
flex: d.stops.filter(s => s.state !== 'planned').length, cap: d.wakingHours * 60 };
@@ -2967,6 +3133,7 @@ function setTravelMode(m) {
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.firstLeg) { d.firstLeg.mode = m; d.firstLeg._routedMode = null; }
if (d.transferIn && d.transferIn.at) { d.transferIn.mode = m; d.transferIn._routedMode = null; }
if (d.retLeg) { d.retLeg.mode = m; d.retLeg._routedMode = null; } // the return ride follows the global choice (rebuildLegs below carries the mode)
rebuildLegs(d, false);
@@ -2974,7 +3141,7 @@ function setTravelMode(m) {
renderAll();
if (day) enrichLegs(day);
renderModeToggle();
- if (l3) (l3.kind === 'arrival' ? openArrivalEditor() : openL3(l3.idx)); // rebind the open route card to the re-estimated leg
+ if (l3) (l3.kind === 'arrival' ? openArrivalEditor() : l3.kind === 'return' ? openReturnEditor() : l3.kind === 'first' ? openFirstEditor() : openL3(l3.idx)); // rebind the open route card to the re-estimated leg
}
function openDetail(s, actions) {
diff --git a/mock/data.js b/mock/data.js
index 83a2be3..b3831ca 100644
--- a/mock/data.js
+++ b/mock/data.js
@@ -686,7 +686,7 @@ const COLOMBIA = {
] } },
{ id: 'S14', name: 'Castillo San Diego', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/Castillo_San_Diego',
- at: [10.42720, -75.55370], dur: 75, durConf: 3,
+ at: [10.42050, -75.55170], dur: 75, durConf: 3,
suggested: { value: 75, conf: 2, src: 'cartagenatourism.com' },
price: { amount: 8, cur: '$', conf: 2, src: 'cartagenatourism.com' },
img: ['⚓', 'Castillo San Diego', 'linear-gradient(135deg,#3c8f8a,#1f5c59)'],
@@ -703,7 +703,7 @@ const COLOMBIA = {
summary: 'Seafood on the Bocagrande malecón — the langosta is the plate, the sunset the side order. Arrive 12:45 for the table with the water view before the 14:00 heat makes the terrace empty.',
links: [ { label: 'TripAdvisor', href: 'https://www.tripadvisor.com' } ] } },
{ id: 'S16', name: 'Malecón de Bocagrande', kind: 'visit', slot: 'visit', state: 'planned',
- at: [10.4365, -75.5390], dur: 60, durConf: 3,
+ at: [10.43545, -75.53900], dur: 60, durConf: 3,
suggested: { value: 60, conf: 2, src: null },
price: null,
img: ['🌊', 'Malecón de Bocagrande', 'linear-gradient(135deg,#4f9ea0,#2c6b6d)'],
@@ -723,7 +723,7 @@ const COLOMBIA = {
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
{ id: 'S18', name: 'Punta Canoa', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/Punta_Canoa',
- at: [10.44600, -75.53100], dur: 90, durConf: 3,
+ at: [10.44030, -75.53000], dur: 90, durConf: 3,
suggested: { value: 90, conf: 2, src: 'cartagenatourism.com' },
price: null,
img: ['🏖️', 'Punta Canoa', 'linear-gradient(135deg,#3cb5c9,#1f7a8a)'],
@@ -732,7 +732,7 @@ const COLOMBIA = {
links: [ { label: 'Punta Canoa', href: 'https://en.wikipedia.org/wiki/Punta_Canoa' } ] } },
{ id: 'S19', name: 'Lunch — beachside at Punta Canoa', kind: 'meal', mealKind: 'lunch',
slot: 'lunch', state: 'planned', url: 'https://www.tripadvisor.com',
- at: [10.44550, -75.53150], dur: 75, durConf: 3,
+ at: [10.44020, -75.52940], dur: 75, durConf: 3,
suggested: { value: 75, conf: 2, src: 'tripadvisor.com' },
price: { amount: 15, cur: '$', conf: 2, src: 'tripadvisor.com' },
img: ['🍤', 'Punta Canoa beach', 'linear-gradient(135deg,#c9803c,#8a541f)'],