diff --git a/mock/app.js b/mock/app.js
index accb1c1..f705763 100644
--- a/mock/app.js
+++ b/mock/app.js
@@ -163,6 +163,34 @@ function relabelDay(d, idx, dateStr) {
d.date = dateStr;
d.label = 'Day ' + idx + ' · ' + DOWS[dt.getUTCDay()] + ' ' + da;
}
+const dayList = () => Object.keys(days).sort().map(k => days[k]);
+const ptEq = (a, b) => !!a && !!b && Math.abs(a[0] - b[0]) < 1e-6 && Math.abs(a[1] - b[1]) < 1e-6;
+// keep hotel stays, city place-dates and hotel booking records in sync after
+// a re-date (e.g. flight re-anchoring) — otherwise stays no longer "cover"
+// the new day dates and the base-hotel logic silently falls back
+function redateStaysAndPlaces() {
+ const all = dayList();
+ const segDates = seg => [seg.reduce((m, d) => d.date < m ? d.date : m, seg[0].date),
+ seg.reduce((m, d) => d.date > m ? d.date : m, seg[0].date)];
+ stays.forEach(st => {
+ const seg = all.filter(d => (d.base && d.base.at && ptEq(d.base.at, st.at)) || covers(st, d.date));
+ if (!seg.length) return;
+ const [a, b] = segDates(seg);
+ st.checkIn = a; st.checkOut = b;
+ });
+ (M.trip.places || []).forEach(pl => {
+ const st = stays.find(s => (s.place || '').toLowerCase().includes(pl.name.toLowerCase())
+ || pl.name.toLowerCase().includes((s.place || '').toLowerCase()));
+ const seg = st ? all.filter(d => covers(st, d.date)) : [];
+ if (!seg.length) return;
+ if (pl.dates) pl.dates = segDates(seg);
+ });
+ (M.trip.bookings || []).forEach(b => {
+ if (b.type !== 'hotel' || !b.dates) return;
+ const st = stays.find(s => s.name === b.name);
+ if (st) b.dates = stayRange(st);
+ });
+}
function rangeStr(a, b) {
const f = s => { const [y, mo, da] = s.split('-').map(Number); return da + ' ' + MONTHS[mo - 1]; };
return a && b ? (a === b ? f(a) : f(a) + '–' + f(b)) : '';
@@ -385,10 +413,20 @@ const TOOLS = {
}
last.wakingHours = Math.max(1, Math.round((depMin - last.startMin) / 60));
- // 5) commit
+ // 5) sync the date-carrying neighbours (stays / places / hotel bookings)
+ redateStaysAndPlaces();
+
+ // 6) commit (persisted immediately — this is the edit we must never lose)
+ // + re-render every date-bearing UI piece (renderAll alone only covers
+ // rail + map, which left tabs, chips and title showing old dates)
ids.forEach(id => rebuildLegs(days[id]));
commit('[flight] re-anchored around booked arrival + departure');
+ persistTrip(true);
renderAll();
+ buildDayTabs();
+ renderBaseChip();
+ renderTripMenu();
+ renderVersionPill();
const fs2 = dayStats(first), ls2 = dayStats(last);
return {
ok: true,
@@ -594,7 +632,11 @@ const hotelWalkMin = (at, d = day) => {
const list = staysForDay(d);
return list.length ? Math.max(1, ...list.map(o => Math.round(haversine(at, o.at) / 1000 / (WALK_KMH / 60)))) : 1;
};
-const stayRange = s => `${s.checkIn.slice(8)}–${s.checkOut.slice(8)} Sep`;
+const stayRange = s => {
+ const [y1, m1, d1] = String(s.checkIn).split('-').map(Number);
+ const [y2, m2, d2] = String(s.checkOut).split('-').map(Number);
+ return m1 === m2 ? `${d1}–${d2} ${MONTHS[m1 - 1]}` : `${d1} ${MONTHS[m1 - 1]}–${d2} ${MONTHS[m2 - 1]}`;
+};
function renderHotelMks() {
hotelMks.forEach(m => map.removeLayer(m)); hotelMks = [];
@@ -1312,7 +1354,7 @@ function moveStopToDay(s, targetId) {
commit(`${s.name} → ${t.label}`); renderAll();
ai(`Moved ${s.name} to ${t.label} — legs and times re-timed there.`, 500);
}
-const dayLabel = () => M.days.find(d => d.id === day.id).label;
+const dayLabel = () => day.label; // live label (dates) — M.days holds the original plan
// ---------------- time budget (gentle phrasing) ----------------
function renderBudget() {
@@ -1689,7 +1731,7 @@ document.addEventListener('keydown', e => {
// ---------------- day tabs ----------------
function buildDayTabs() {
const tabs = $('#daytabs'); tabs.innerHTML = '';
- M.days.forEach(d => {
+ dayList().forEach(d => { // live days — M.days holds the pre-edit originals
const t = el('button', 'daytab' + (d.id === curDay ? ' sel' : ''), d.label);
t.onclick = () => setDay(d.id);
t.addEventListener('dragover', e => { e.preventDefault(); t.classList.add('over'); });
@@ -1711,7 +1753,7 @@ function buildDayTabs() {
function setDay(id) {
curDay = id; day = days[id];
if (!day.legs.length) rebuildLegs(day);
- document.querySelectorAll('.daytab').forEach((t, i) => t.classList.toggle('sel', M.days[i] && M.days[i].id === id));
+ document.querySelectorAll('.daytab').forEach((t, i) => t.classList.toggle('sel', dayList()[i] && dayList()[i].id === id));
closeL3(); // a selected route from another day is stale
tempClear();
if (disc) closeDiscover(); // the drawer’s anchor is day-specific
@@ -1755,7 +1797,7 @@ function renderTripRail() {
const pct = Math.min(100, st.dur / st.cap * 100);
const card = el('div', 'trip-day' + (md.id === curDay ? ' cur' : ''));
const top = el('div', 'td-top');
- top.append(el('span', 'td-label', md.label + (d.isDeparture ? ' · dep' : '')));
+ top.append(el('span', 'td-label', d.label + (d.isDeparture ? ' · dep' : ''))); // live label (dates)
top.append(el('span', 'td-end', `ends ~${fmt(st.end)}`));
card.append(top);
const bar = el('div', 'td-bar');
@@ -1919,7 +1961,7 @@ function docSection() {
// capped at 60k chars server-side — well within the 131k-token window)
const ds = Object.values(tripDocs);
if (!ds.length) return '';
- return '\n\nAttached documents (the user’s own files, kept for reference on any question). For questions about their real bookings (confirmation codes, seats, prices, flight times), answer from these documents, not from the plan’s placeholder bookings:\n' +
+ return '\n\nAttached documents (the user’s own files, kept for reference on any question). These are ALL the documents on file for this trip — when one is missing a detail (e.g. an e-mail that only covers the outbound flight), look for it in the OTHERS before asking the user. For questions about their real bookings (confirmation codes, seats, prices, flight times), answer from these documents, not from the plan’s placeholder bookings:\n' +
ds.map(d => 'DOCUMENT “' + d.name + '”' + (d.truncated ? ' (extract truncated)' : '') + '\n' + (d.text || '')).join('\n\n');
}
function renderDocChips() {
@@ -1942,7 +1984,7 @@ function reconfigureFromDoc(j) {
'DOCUMENT “' + (j.name || 'document') + '”\n' +
'\n' + (j.text || '') + '\n\n\n' +
'If this is a flight booking (or contains actual flight times), reconfigure the trip around it:\n' +
- '1. Read the ARRIVAL (date, local time, airport, city) and the DEPARTURE (date, local time, airport, city). With connections, the arrival is where the trip starts (first city) and the departure where it ends (last city).\n' +
+ '1. Read the ARRIVAL (date, local time, airport, city) and the DEPARTURE (date, local time, airport, city). With connections, the arrival is where the trip starts (first city) and the departure where it ends (last city). If this document only shows one leg, search the OTHER attached documents in your context for the missing leg before asking the user.\n' +
'2. Call apply_flight_anchors exactly once with those two anchors — apply it now, do not ask first (the edit is undo-able), even if the dates differ from the current plan.\n' +
'3. Confirm what changed — the new first and last day with their start/end times, any stops that had to go, and the date shift — in a couple of sentences.\n' +
'If it is NOT a flight booking, do not change the plan — just confirm it’s attached and summarize what it contains in 1–2 sentences.';
@@ -2007,12 +2049,14 @@ const tripStore = {
load(id) { try { const s = localStorage.getItem(this.key(id)); return s ? JSON.parse(s) : null; } catch { return null; } },
};
let persistT = 0;
-function persistTrip() {
+function persistTrip(force = false) {
clearTimeout(persistT);
const hist = history.slice(0, hIdx + 1).slice(-25); // drop the redo tail, cap at 25
// docs + chat live on the server now (mock/store//) — localStorage
- // only carries the plan state
- persistT = setTimeout(() => tripStore.save(tripId, { days, stays, version, hIdx: hist.length - 1, history: hist, bookings: M.trip.bookings, title: M.trip.title }), 250);
+ // only carries the plan state. force: skip the debounce — the re-anchoring
+ // of the whole trip around booked flights is the one edit we must never lose
+ const save = () => tripStore.save(tripId, { days, stays, version, hIdx: hist.length - 1, history: hist, bookings: M.trip.bookings, title: M.trip.title });
+ if (force) save(); else persistT = setTimeout(save, 250);
}
function renderTripMenu() {
$('#crumb').innerHTML = `🧳 ${M.trip.title} ▾`;
diff --git a/mock/server.js b/mock/server.js
index 44f6b42..386ecd7 100644
--- a/mock/server.js
+++ b/mock/server.js
@@ -181,19 +181,29 @@ function looksLikeMime(t) {
return /MIME-Version:\s*1\.0/im.test(h) || /Content-Transfer-Encoding:\s*(base64|quoted-printable)/im.test(h);
}
const decodeQP = (s) => String(s).replace(/=\r?\n/g, '').replace(/=([0-9a-fA-F]{2})/g, (_, hx) => String.fromCharCode(parseInt(hx, 16)));
+// top-level header field (starts at column 0 — mail metadata headers like
+// "X-Arc: ...content-type:from:subject:..." embed field names in their
+// values, so an unanchored case-insensitive match grabs the wrong one);
+// last occurrence wins (real headers come after the metadata lines)
+const headerField = (h, name) => {
+ const re = new RegExp('^' + name + ':\\s*([^\\r\\n]+)', 'gim');
+ let m, last = null;
+ while ((m = re.exec(h))) last = m[1];
+ return last ? last.trim().toLowerCase() : '';
+};
function decodeMime(raw) {
// best text part (html preferred, then longest); handles one level of
// nested multipart; returns null if nothing decodable was found
const head = raw.slice(0, 20000);
- const bm = head.match(/boundary="?([^";\r\n]+)"?/i);
+ const bm = (head.match(/^boundary="?([^";\r\n]+)"?/im) || head.match(/boundary="?([^";\r\n]+)"?/i));
const parts = [];
if (bm) {
for (const chunk of raw.split(bm[1])) {
const nm = chunk.match(/\r?\n([\s\S]*?)\r?\n\r?\n([\s\S]*)/);
if (!nm) continue;
const h = nm[1];
- const ct = ((h.match(/Content-Type:\s*([^\r\n;]+)/i) || [])[1] || '').trim().toLowerCase();
- const ce = ((h.match(/Content-Transfer-Encoding:\s*([^\r\n]+)/i) || [])[1] || '').trim().toLowerCase();
+ const ct = headerField(h, 'Content-Type').split(';')[0];
+ const ce = headerField(h, 'Content-Transfer-Encoding');
const body = nm[2].replace(/\n--\s*$/, '');
if (body.trim()) parts.push({ ct, ce, body });
}
@@ -201,7 +211,7 @@ function decodeMime(raw) {
const nm = raw.match(/\r?\n([\s\S]*?)\r?\n\r?\n([\s\S]*)/);
if (nm) {
const h = nm[1];
- parts.push({ ct: ((h.match(/Content-Type:\s*([^\r\n;]+)/i) || [])[1] || '').trim().toLowerCase(), ce: ((h.match(/Content-Transfer-Encoding:\s*([^\r\n]+)/i) || [])[1] || '').trim().toLowerCase(), body: nm[2] });
+ parts.push({ ct: headerField(h, 'Content-Type').split(';')[0], ce: headerField(h, 'Content-Transfer-Encoding'), body: nm[2] });
}
}
const dec = [];